diff --git a/Makefile b/Makefile index 525f8c3e..c0908e8f 100644 --- a/Makefile +++ b/Makefile @@ -48,7 +48,7 @@ coverage.xml: pytest behave-all behave-translator .Phony: ci-test ci-test: | toggle-local build migrate run coverage.xml -## cleanup: remove local containers and volumes +## clean: remove local containers and volumes .Phony: clean clean: docker-compose.yaml @docker compose rm -f -s diff --git a/config/api_router.py b/config/api_router.py index 06b95b54..a5b77f57 100644 --- a/config/api_router.py +++ b/config/api_router.py @@ -1,11 +1,6 @@ from rest_framework.routers import DefaultRouter -from scram.route_manager.api.views import ( - ActionTypeViewSet, - ClientViewSet, - EntryViewSet, - IgnoreEntryViewSet, -) +from scram.route_manager.api.views import ActionTypeViewSet, ClientViewSet, EntryViewSet, IgnoreEntryViewSet from scram.users.api.views import UserViewSet router = DefaultRouter() diff --git a/config/consumers.py b/config/consumers.py index 7925ff29..07895c4b 100644 --- a/config/consumers.py +++ b/config/consumers.py @@ -37,6 +37,8 @@ async def _send_event(self, event): translator_add = _send_event # Tell all translators of this actiontype of a withdrawal of a route. translator_remove = _send_event + # Tell all translators of this actiontype to withdraw ALL routes. + translator_remove_all = _send_event # Send a query to all translators if a route is announced. translator_check = _send_event diff --git a/local.yml b/local.yml index e42895c4..24519815 100644 --- a/local.yml +++ b/local.yml @@ -70,6 +70,23 @@ services: - "7000" command: /start-docs + docs: + image: scram_local_docs + build: + context: . + dockerfile: ./compose/local/docs/Dockerfile + env_file: + - ./.envs/.local/.django + networks: + default: {} + volumes: + - $CI_PROJECT_DIR/docs:/docs:z + - $CI_PROJECT_DIR/config:/app/config:z + - $CI_PROJECT_DIR/scram:/app/scram:z + ports: + - "7000" + command: /start-docs + redis: image: redis:5.0 sysctls: diff --git a/scram/route_manager/admin.py b/scram/route_manager/admin.py index ae0ede20..96a6e324 100644 --- a/scram/route_manager/admin.py +++ b/scram/route_manager/admin.py @@ -1,7 +1,7 @@ from django.contrib import admin from simple_history.admin import SimpleHistoryAdmin -from .models import ActionType, Client, Entry, IgnoreEntry, Route +from .models import ActionType, Client, Entry, IgnoreEntry, Route, WebSocketMessage, WebSocketSequenceElement @admin.register(ActionType) @@ -14,3 +14,5 @@ class ActionTypeAdmin(SimpleHistoryAdmin): admin.site.register(IgnoreEntry, SimpleHistoryAdmin) admin.site.register(Route) admin.site.register(Client) +admin.site.register(WebSocketMessage) +admin.site.register(WebSocketSequenceElement) diff --git a/scram/route_manager/api/views.py b/scram/route_manager/api/views.py index b4efa0fc..2972e6dd 100644 --- a/scram/route_manager/api/views.py +++ b/scram/route_manager/api/views.py @@ -12,14 +12,9 @@ from rest_framework.permissions import AllowAny, IsAuthenticated from rest_framework.response import Response -from ..models import ActionType, Client, Entry, IgnoreEntry +from ..models import ActionType, Client, Entry, IgnoreEntry, WebSocketSequenceElement from .exceptions import ActiontypeNotAllowed, IgnoredRoute, PrefixTooLarge -from .serializers import ( - ActionTypeSerializer, - ClientSerializer, - EntrySerializer, - IgnoreEntrySerializer, -) +from .serializers import ActionTypeSerializer, ClientSerializer, EntrySerializer, IgnoreEntrySerializer channel_layer = get_channel_layer() @@ -106,11 +101,17 @@ def perform_create(self, serializer): logging.info(f"Cannot proceed adding {route}. The ignore list contains {ignore_entries}") raise IgnoredRoute else: - # Must match a channel name defined in asgi.py - async_to_sync(channel_layer.group_send)( - f"translator_{actiontype}", - {"type": "translator_add", "message": {"route": str(route)}}, - ) + elements = WebSocketSequenceElement.objects.filter(action_type__name=actiontype).order_by("order_num") + if not elements: + logging.warning(f"No elements found for actiontype={actiontype}.") + + for element in elements: + msg = element.websocketmessage + msg.msg_data[msg.msg_data_route_field] = str(route) + # Must match a channel name defined in asgi.py + async_to_sync(channel_layer.group_send)( + f"translator_{actiontype}", {"type": msg.msg_type, "message": msg.msg_data} + ) serializer.save() diff --git a/scram/route_manager/migrations/0027_websocketmessage_websocketsequenceelement.py b/scram/route_manager/migrations/0027_websocketmessage_websocketsequenceelement.py new file mode 100644 index 00000000..a04fdc46 --- /dev/null +++ b/scram/route_manager/migrations/0027_websocketmessage_websocketsequenceelement.py @@ -0,0 +1,56 @@ +# Generated by Django 3.2.13 on 2024-01-06 21:48 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("route_manager", "0026_alter_client_hostname"), + ] + + operations = [ + migrations.CreateModel( + name="WebSocketMessage", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ("msg_type", models.CharField(max_length=50, verbose_name="The type of the message")), + ( + "msg_data", + models.JSONField(default=dict, verbose_name="The JSON payload. See also msg_data_route_field."), + ), + ( + "msg_data_route_field", + models.CharField( + max_length=25, + verbose_name="The key in the JSON payload whose value will contain the route being acted on.", + ), + ), + ], + ), + migrations.CreateModel( + name="WebSocketSequenceElement", + fields=[ + ("id", models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")), + ( + "order_num", + models.SmallIntegerField( + default=0, + verbose_name="Sequences are sent from the smallest order_num to the highest. Messages with the same order_num could be sent in any order", + ), + ), + ("verb", models.CharField(choices=[("A", "Add"), ("C", "Check"), ("R", "Remove")], max_length=1)), + ( + "action_type", + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to="route_manager.actiontype"), + ), + ( + "websocketmessage", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="route_manager.websocketmessage" + ), + ), + ], + ), + ] diff --git a/scram/route_manager/migrations/0028_default_websocket_messages.py b/scram/route_manager/migrations/0028_default_websocket_messages.py new file mode 100644 index 00000000..ce2cf44f --- /dev/null +++ b/scram/route_manager/migrations/0028_default_websocket_messages.py @@ -0,0 +1,25 @@ +from django.db import migrations + + +def create(apps, schema_editor): + ActionType = apps.get_model("route_manager", "ActionType") + at = ActionType.objects.get(name="block") + + WebSocketMessage = apps.get_model("route_manager", "WebSocketMessage") + wsm = WebSocketMessage(msg_type="translator_add", msg_data_route_field="route") + wsm.save() + + WebSocketSequenceElement = apps.get_model("route_manager", "WebSocketSequenceElement") + wsse = WebSocketSequenceElement(websocketmessage=wsm, verb="A", action_type=at) + wsse.save() + + +class Migration(migrations.Migration): + + dependencies = [ + ("route_manager", "0027_websocketmessage_websocketsequenceelement"), + ] + + operations = [ + migrations.RunPython(create), + ] diff --git a/scram/route_manager/migrations/0029_alter_websocketmessage_msg_data_route_field.py b/scram/route_manager/migrations/0029_alter_websocketmessage_msg_data_route_field.py new file mode 100644 index 00000000..515b330c --- /dev/null +++ b/scram/route_manager/migrations/0029_alter_websocketmessage_msg_data_route_field.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.13 on 2024-01-09 16:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("route_manager", "0028_default_websocket_messages"), + ] + + operations = [ + migrations.AlterField( + model_name="websocketmessage", + name="msg_data_route_field", + field=models.CharField( + default="route", + max_length=25, + verbose_name="The key in the JSON payload whose value will contain the route being acted on.", + ), + ), + ] diff --git a/scram/route_manager/models.py b/scram/route_manager/models.py index 7a1ada8d..ef063ead 100644 --- a/scram/route_manager/models.py +++ b/scram/route_manager/models.py @@ -35,6 +35,47 @@ def __str__(self): return self.name +class WebSocketMessage(models.Model): + """Defines a single message sent to downstream translators via WebSocket.""" + + msg_type = models.CharField("The type of the message", max_length=50) + msg_data = models.JSONField("The JSON payload. See also msg_data_route_field.", default=dict) + msg_data_route_field = models.CharField( + "The key in the JSON payload whose value will contain the route being acted on.", + default="route", + max_length=25, + ) + + def __str__(self): + return f"{self.msg_type}: {self.msg_data} with the route in key {self.msg_data_route_field}" + + +class WebSocketSequenceElement(models.Model): + """In a sequence of messages, defines a single element.""" + + websocketmessage = models.ForeignKey("WebSocketMessage", on_delete=models.CASCADE) + order_num = models.SmallIntegerField( + "Sequences are sent from the smallest order_num to the highest. " + + "Messages with the same order_num could be sent in any order", + default=0, + ) + + VERB_CHOICES = [ + ("A", "Add"), + ("C", "Check"), + ("R", "Remove"), + ] + verb = models.CharField(max_length=1, choices=VERB_CHOICES) + + action_type = models.ForeignKey("ActionType", on_delete=models.CASCADE) + + def __str__(self): + return ( + f"{self.websocketmessage} as order={self.order_num} for " + + f"{self.verb} actions on actiontype={self.action_type}" + ) + + class Entry(models.Model): """An instance of an action taken on a route.""" diff --git a/scram/route_manager/tests/acceptance/features/add_automated_block_entry.feature b/scram/route_manager/tests/acceptance/features/add_automated_block_entry.feature index cedd1b97..83af7dc5 100644 --- a/scram/route_manager/tests/acceptance/features/add_automated_block_entry.feature +++ b/scram/route_manager/tests/acceptance/features/add_automated_block_entry.feature @@ -2,11 +2,12 @@ Feature: an automated source adds a block entry Automated clients (eg zeek) can add v4/v6 block entries Scenario: unauthenticated users get a 403 - When we add the entry 127.0.0.1 + When we add the entry 192.0.2.132 Then we get a 403 status code Scenario Outline: add a block entry - Given a client with block authorization + Given a block actiontype is defined + And a client with block authorization When we're logged in And we add the entry And we list the entrys @@ -18,21 +19,21 @@ Feature: an automated source adds a block entry Examples: v4 IPs | ip | cidr | - | 1.2.3.4 | 1.2.3.4/32 | - | 193.168.0.0 | 193.168.0.0/32 | + | 192.0.2.128 | 192.0.2.128/32 | + | 192.0.2.129 | 192.0.2.129/32 | Examples: v6 IPs - | ip | cidr | - | 2000:: | 2000::/128 | - | ::1 | ::1/128 | + | ip | cidr | + | 2001:DB8:94BB::94BC | 2001:DB8:94BB::94BC/128 | + | 2001:DB8:94BC:: | 2001:DB8:94BC::/128 | @history Scenario: add a block entry with a comment Given a client with block authorization When we're logged in - And we add the entry 127.0.0.2 with comment it's coming from inside the house + And we add the entry 192.0.2.133 with comment it's coming from inside the house Then we get a 201 status code - And the change entry for 127.0.0.2 is it's coming from inside the house + And the change entry for 192.0.2.133 is it's coming from inside the house Scenario Outline: add a block entry multiple times and it's accepted Given a client with block authorization @@ -44,11 +45,11 @@ Feature: an automated source adds a block entry And the number of entrys is 1 Examples: IPs - | ip | - | 1.2.3.4 | - | 193.168.0.0 | - | 2000:: | - | ::1 | + | ip | + | 192.0.2.130 | + | 198.51.100.130 | + | 2001:DB8:94BD::94BD | + | 2001:DB8:94BE:: | Scenario Outline: invalid block entries can't be added Given a client with block authorization @@ -69,7 +70,8 @@ Feature: an automated source adds a block entry | 2000::/129 | Scenario Outline: add a block entry as a cidr address - Given a client with block authorization + Given a block actiontype is defined + And a client with block authorization When we're logged in And the CIDR prefix limits are 8 and 32 And we add the entry @@ -80,7 +82,7 @@ Feature: an automated source adds a block entry Examples: | ip | - | 1.2.3.4/32 | - | 10.1.0.0/16 | - | 2000::1/128 | - | 2001:4f8:3:ba::/64 | + | 192.0.2.131/32 | + | 198.51.100.160/29 | + | 2001:DB8:94BA::94BD/128 | + | 2001:DB8:94BE::/64 | diff --git a/scram/route_manager/tests/acceptance/features/client.feature b/scram/route_manager/tests/acceptance/features/client.feature index b5e4ea75..493c4531 100644 --- a/scram/route_manager/tests/acceptance/features/client.feature +++ b/scram/route_manager/tests/acceptance/features/client.feature @@ -7,11 +7,11 @@ Feature: We can register and use clients Scenario: We can add a block using an authorized client Given a client with block authorization When we're logged in - And we add the entry 1.2.3.4 + And we add the entry 192.0.2.216 Then we get a 201 status code Scenario: We can't block with an unauthorized client even if we are logged in Given a client without block authorization When we're logged in - And we add the entry 1.2.3.4 + And we add the entry 192.0.2.217 Then we get a 403 status code diff --git a/scram/route_manager/tests/acceptance/features/expiration.feature b/scram/route_manager/tests/acceptance/features/expiration.feature index 3deb7402..27b1684f 100644 --- a/scram/route_manager/tests/acceptance/features/expiration.feature +++ b/scram/route_manager/tests/acceptance/features/expiration.feature @@ -2,58 +2,62 @@ Feature: entries auto-expire Entries get semi-auto-expired Scenario: Adding an IP expires after calling process-expiration + Given a block actiontype is defined Given a client with block authorization When we're logged in - And we add the entry 1.2.3.1/32 with expiration 1970-01-01T00:00:00Z - And we add the entry 1.2.3.2/32 with expiration 2000-01-01T00:00:00Z - And we add the entry 1.2.3.3/32 with expiration 2030-01-01T00:00:00Z - And we add the entry 1.2.3.4/32 with expiration in 12 seconds + And we add the entry 192.0.2.1/32 with expiration 1970-01-01T00:00:00Z + And we add the entry 192.0.2.2/32 with expiration 2000-01-01T00:00:00Z + And we add the entry 192.0.2.3/32 with expiration 2030-01-01T00:00:00Z + And we add the entry 192.0.2.4/32 with expiration in 12 seconds Then the number of entrys is 4 - And 1.2.3.1/32 is announced by block translators - And 1.2.3.2/32 is announced by block translators - And 1.2.3.3/32 is announced by block translators - And 1.2.3.4/32 is announced by block translators + And 192.0.2.1/32 is announced by block translators + And 192.0.2.2/32 is announced by block translators + And 192.0.2.3/32 is announced by block translators + And 192.0.2.4/32 is announced by block translators And we remove expired entries And the number of entrys is 2 - And 1.2.3.1/32 is not announced by block translators - And 1.2.3.3/32 is announced by block translators + And 192.0.2.1/32 is not announced by block translators + And 192.0.2.3/32 is announced by block translators And we wait 12 seconds And we remove expired entries And the number of entrys is 1 Scenario: Adding an IP expires after calling process-expiration + Given a block actiontype is defined Given a client with block authorization When we're logged in - And we add the entry 1.2.3.1/32 with expiration 1970-01-01T00:00:00Z - And we add the entry 1.2.3.2/32 with expiration 2000-01-01T00:00:00Z - And we add the entry 1.2.3.3/32 with expiration 2030-01-01T00:00:00Z + And we add the entry 192.0.2.1/32 with expiration 1970-01-01T00:00:00Z + And we add the entry 192.0.2.2/32 with expiration 2000-01-01T00:00:00Z + And we add the entry 192.0.2.3/32 with expiration 2030-01-01T00:00:00Z Then the number of entrys is 3 - And 1.2.3.1/32 is announced by block translators - And 1.2.3.3/32 is announced by block translators + And 192.0.2.1/32 is announced by block translators + And 192.0.2.3/32 is announced by block translators And we remove expired entries And the number of entrys is 1 - And 1.2.3.1/32 is not announced by block translators - And 1.2.3.3/32 is announced by block translators + And 192.0.2.1/32 is not announced by block translators + And 192.0.2.3/32 is announced by block translators Scenario: Adding an IP twice gets expired after the last entry expires + Given a block actiontype is defined Given a client with block authorization When we're logged in - And we add the entry 1.2.3.1/32 with expiration 1970-01-01T00:00:00Z - And we add the entry 1.2.3.1/32 with expiration 2030-01-01T00:00:00Z - Then 1.2.3.1/32 is announced by block translators + And we add the entry 192.0.2.1/32 with expiration 1970-01-01T00:00:00Z + And we add the entry 192.0.2.1/32 with expiration 2030-01-01T00:00:00Z + Then 192.0.2.1/32 is announced by block translators And we remove expired entries - And 1.2.3.1/32 is announced by block translators + And 192.0.2.1/32 is announced by block translators Scenario: Adding an IP twice gets expired after the last entry expires in 2 seconds + Given a block actiontype is defined Given a client with block authorization When we're logged in - And we add the entry 1.2.3.1/32 with expiration 1970-01-01T00:00:00Z - And we add the entry 1.2.3.1/32 with expiration in 12 seconds - Then 1.2.3.1/32 is announced by block translators + And we add the entry 192.0.2.1/32 with expiration 1970-01-01T00:00:00Z + And we add the entry 192.0.2.1/32 with expiration in 12 seconds + Then 192.0.2.1/32 is announced by block translators And we remove expired entries And we wait 1 seconds - And 1.2.3.1/32 is announced by block translators + And 192.0.2.1/32 is announced by block translators And we wait 13 seconds And we remove expired entries - And 1.2.3.1/32 is not announced by block translators + And 192.0.2.1/32 is not announced by block translators diff --git a/scram/route_manager/tests/acceptance/features/ignorelist.feature b/scram/route_manager/tests/acceptance/features/ignorelist.feature index fbacc882..a78a1acb 100644 --- a/scram/route_manager/tests/acceptance/features/ignorelist.feature +++ b/scram/route_manager/tests/acceptance/features/ignorelist.feature @@ -9,30 +9,32 @@ Feature: The ignorelist keeps us from blocking a cidr we have ignorelisted And is contained in our list of ignoreentrys Examples: v4 IPs - | ip | - | 1.2.3.4 | - | 193.168.0.0 | + | ip | + | 192.0.2.4 | + | 198.51.100.255 | Examples: v6 IPs - | ip | - | 2000:: | - | ::1 | + | ip | + | 2001:DB8:: | + | 2001:DB8::1 | Scenario Outline: we can't block an entry from the ignore list - Given a client with block authorization + Given a block actiontype is defined + And a client with block authorization When we're logged in + And the CIDR prefix limits are 16 and 48 And we add the ignore entry And we add the entry Then we get a 400 status code Examples: v4 IPs - | entry | ignore | - | 2.2.2.2 | 2.2.2.2/32 | - | 1.2.3.4 | 1.2.3.0/24 | - | 193.168.0.0/24 | 193.168.0.2 | + | entry | ignore | + | 192.0.2.2 | 192.0.2.2/32 | + | 192.0.2.129 | 192.0.2.128/25 | + | 198.51.100.0/24 | 198.51.100.1 | Examples: v6 IPs - | entry | ignore | - | ::1 | ::1/128 | - | 2000::1 | 2000::/64 | - | 193::/64 | 193::2 | + | entry | ignore | + | 2001:DB8::1 | 2001:DB8::1/128 | + | 2001:DB8:ABCD::1 | 2001:DB8:ABCD::/64 | + | 2001:DB8:DEAD::/64 | 2001:DB8:DEAD::2 | diff --git a/scram/route_manager/tests/acceptance/features/query.feature b/scram/route_manager/tests/acceptance/features/query.feature index 00a30750..534399fb 100644 --- a/scram/route_manager/tests/acceptance/features/query.feature +++ b/scram/route_manager/tests/acceptance/features/query.feature @@ -10,11 +10,11 @@ Feature: we can query the list of entries for a specific entry Then we get a 200 status code Examples: IPs - | ip | - | 1.2.3.4 | - | 2.0.0.0/8 | - | 2001::1 | - | 201::0/32 | + | ip | + | 192.0.2.168 | + | 192.0.2.176/29 | + | 2001:DB8:9508::1 | + | 2001:DB8:9508::/48 | Scenario Outline: we can add a host and then query based on other parts of the CIDR Given a client with block authorization @@ -25,33 +25,32 @@ Feature: we can query the list of entries for a specific entry Then we get a 200 status code Examples: v4 - | ip | cidr | - | 1.2.3.4/32 | 1.0.0.0/8 | - | 2.0.0.0/8 | 2.1.1.1 | - | 2.0.0.0/8 | 2.1.1.1/32 | - | 2.0.0.0/8 | 2.1.1.1/15 | + | ip | cidr | + | 192.0.2.3/32 | 192.0.2.0/28 | + | 192.0.2.32/28 | 192.0.2.35/32 | + | 192.0.2.128/28 | 192.0.2.137/29 | Examples: v6 - | ip | cidr | - | 2001::1/128 | 2001::/32 | - | 2001::/32 | 2001::1 | - | 2001::/32 | 2001::1/128 | - | 2001::/32 | 2001::1/64 | + | ip | cidr | + | 2001:DB8:950A::/128 | 2001:DB8:950A::/48 | + | 2001:DB8:950B::/48 | 2001:DB8:950B::1 | + | 2001:DB8:950C::/48 | 2001:DB8:950C::1/128 | + | 2001:DB8:950D::/48 | 2001:DB8:950D::1/64 | Scenario Outline: we cant query larger than our prefixmin Given a client with block authorization When we're logged in - And the CIDR prefix limits are 1 and 1 + And the CIDR prefix limits are 24 and 48 And we add the entry - And the CIDR prefix limits are 8 and 32 + And the CIDR prefix limits are 32 and 128 And we query for Then we get a 400 status code Examples: IPs | ip | - | 2.0.0.0/7 | - | 201::0/31 | + | 192.0.2.0/24 | + | 2001:DB8::/48 | Scenario Outline: we cant enter malformed IPs Given a client with block authorization diff --git a/scram/route_manager/tests/acceptance/features/remove_ip.feature b/scram/route_manager/tests/acceptance/features/remove_ip.feature index dc32f539..8ede73b7 100644 --- a/scram/route_manager/tests/acceptance/features/remove_ip.feature +++ b/scram/route_manager/tests/acceptance/features/remove_ip.feature @@ -3,7 +3,7 @@ Feature: remove a network Scenario: unauthenticated users get a 403 Given a client without block authorization - When we add the entry 127.0.0.1 + When we add the entry 192.0.2.192 Then we get a 403 status code Scenario Outline: removing a nonexistant IP returns a 204 (the client should not have to worry about it) @@ -14,12 +14,12 @@ Feature: remove a network Examples: Made Up Primary Key | PK | | 1 | - | 9.9.9.9 | + | 192.0.2.9 | Scenario: removing an existing IP returns a 204 Given a client with block authorization When we're logged in - And we add the entry 1.2.3.4/32 - And we remove the entry 1.2.3.4/32 + And we add the entry 192.0.2.193/32 + And we remove the entry 192.0.2.193/32 Then we get a 204 status code And the number of entrys is 0 diff --git a/scram/route_manager/tests/acceptance/features/restrict_changes.feature b/scram/route_manager/tests/acceptance/features/restrict_changes.feature index 3bb1359f..bb24eddc 100644 --- a/scram/route_manager/tests/acceptance/features/restrict_changes.feature +++ b/scram/route_manager/tests/acceptance/features/restrict_changes.feature @@ -2,11 +2,12 @@ Feature: restrict changing entries We do not want users updating a route after it has been added; a change should be a new object. Scenario: user can't update a route - Given a client with block authorization + Given a block actiontype is defined + And a client with block authorization When we're logged in - And we add the entry 1.2.3.4 - And we update the entry 1.2.3.4 to 1.2.3.5 + And we add the entry 192.0.2.208 + And we update the entry 192.0.2.208 to 192.0.2.209 Then we get a 405 status code And the number of entrys is 1 - And 1.2.3.4 is announced by block translators - And 1.2.3.5 is not announced by block translators + And 192.0.2.208 is announced by block translators + And 192.0.2.209 is not announced by block translators diff --git a/scram/route_manager/tests/acceptance/steps/common.py b/scram/route_manager/tests/acceptance/steps/common.py index eaab208e..1d502e96 100644 --- a/scram/route_manager/tests/acceptance/steps/common.py +++ b/scram/route_manager/tests/acceptance/steps/common.py @@ -2,33 +2,46 @@ import time import django.conf as conf +from asgiref.sync import async_to_sync from behave import given, step, then, when +from channels.layers import get_channel_layer from django.urls import reverse -from scram.route_manager.models import ActionType, Client +from scram.route_manager.models import ActionType, Client, WebSocketMessage, WebSocketSequenceElement @given("a {name} actiontype is defined") -def define_block(context, name): +def step_impl(context, name): + context.channel_layer = get_channel_layer() + async_to_sync(context.channel_layer.group_send)( + f"translator_{name}", {"type": "translator_remove_all", "message": {}} + ) + at, created = ActionType.objects.get_or_create(name=name) + wsm, created = WebSocketMessage.objects.get_or_create(msg_type="translator_add", msg_data_route_field="route") + wsm.save() + wsse, created = WebSocketSequenceElement.objects.get_or_create(websocketmessage=wsm, verb="A", action_type=at) + wsse.save() -@given("a client with block authorization") -def define_block(context): +@given("a client with {name} authorization") +def step_impl(context, name): + at, created = ActionType.objects.get_or_create(name=name) authorized_client = Client.objects.create( hostname="authorized_client.es.net", uuid="0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", is_authorized=True, ) - authorized_client.authorized_actiontypes.set([1]) + authorized_client.authorized_actiontypes.set([at]) -@given("a client without block authorization") -def define_block(context): - Client.objects.create( +@given("a client without {name} authorization") +def step_impl(context, name): + unauthorized_client = Client.objects.create( hostname="unauthorized_client.es.net", uuid="91e134a5-77cf-4560-9797-6bbdbffde9f8", ) + unauthorized_client.authorized_actiontypes.set([]) @when("we're logged in") @@ -59,6 +72,7 @@ def step_impl(context, value): "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", "who": "person", }, + format="json", ) @@ -72,6 +86,7 @@ def step_impl(context, value, comment): "comment": comment, # Authorized uuid "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", + "who": "person", }, ) @@ -87,6 +102,7 @@ def step_impl(context, value, exp): "expiration": exp, # Authorized uuid "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", + "who": "person", }, ) @@ -105,6 +121,7 @@ def step_impl(context, value, secs): "expiration": expiration, # Authorized uuid "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", + "who": "person", }, ) @@ -161,7 +178,7 @@ def step_impl(context, value, model): for obj in objs.json(): # For some models, we need to look at a different field. model = model_to_field_mapping.get(model.lower(), model.lower()) - if obj[model] == value: + if obj[model].lower() == value.lower(): found = True break diff --git a/scram/route_manager/tests/test_api.py b/scram/route_manager/tests/test_api.py index ebb0e6ac..8152b877 100644 --- a/scram/route_manager/tests/test_api.py +++ b/scram/route_manager/tests/test_api.py @@ -22,7 +22,7 @@ def test_block_ipv4(self): response = self.client.post( self.url, { - "route": "1.2.3.4", + "route": "192.0.2.4", "comment": "test", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", }, @@ -34,7 +34,7 @@ def test_block_duplicate_ipv4(self): self.client.post( self.url, { - "route": "1.2.3.4", + "route": "192.0.2.4", "comment": "test", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", }, @@ -43,7 +43,7 @@ def test_block_duplicate_ipv4(self): response = self.client.post( self.url, { - "route": "1.2.3.4", + "route": "192.0.2.4", "comment": "test", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", }, @@ -94,7 +94,7 @@ def test_unauthenticated_users_have_no_create_access(self): response = self.client.post( self.entry_url, { - "route": "1.2.3.4", + "route": "192.0.2.4", "comment": "test", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", "who": "person", @@ -104,7 +104,7 @@ def test_unauthenticated_users_have_no_create_access(self): self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_unauthenticated_users_have_no_ignore_create_access(self): - response = self.client.post(self.ignore_url, {"route": "1.2.3.4"}, format="json") + response = self.client.post(self.ignore_url, {"route": "192.0.2.4"}, format="json") self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) def test_unauthenticated_users_have_no_list_access(self): diff --git a/scram/route_manager/tests/test_authorization.py b/scram/route_manager/tests/test_authorization.py index 6715f948..6f4269d1 100644 --- a/scram/route_manager/tests/test_authorization.py +++ b/scram/route_manager/tests/test_authorization.py @@ -53,7 +53,7 @@ def create_entry(self): self.client.post( reverse("route_manager:add"), { - "route": "3.2.3.4/32", + "route": "192.0.2.199/32", "actiontype": "block", "comment": "create entry", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", @@ -71,7 +71,7 @@ def test_unauthorized_add_entry(self): response = self.client.post( reverse("route_manager:add"), { - "route": "1.2.3.4/32", + "route": "192.0.2.4/32", "actiontype": "block", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", }, @@ -84,7 +84,7 @@ def test_authorized_add_entry(self): response = self.client.post( reverse("route_manager:add"), { - "route": "1.2.3.4/32", + "route": "192.0.2.4/32", "actiontype": "block", "uuid": "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3", }, @@ -116,12 +116,12 @@ def test_unauthorized_after_group_removal(self): test_user.save() self.client.force_login(test_user) - response = self.client.post(reverse("route_manager:add"), {"route": "1.2.3.4/32", "actiontype": "block"}) + response = self.client.post(reverse("route_manager:add"), {"route": "192.0.2.4/32", "actiontype": "block"}) self.assertEqual(response.status_code, 200) test_user.groups.set([]) - response = self.client.post(reverse("route_manager:add"), {"route": "1.2.3.5/32", "actiontype": "block"}) + response = self.client.post(reverse("route_manager:add"), {"route": "192.0.2.5/32", "actiontype": "block"}) self.assertEqual(response.status_code, 302) diff --git a/scram/route_manager/tests/test_history.py b/scram/route_manager/tests/test_history.py index 5780280a..f00f16bb 100644 --- a/scram/route_manager/tests/test_history.py +++ b/scram/route_manager/tests/test_history.py @@ -16,14 +16,14 @@ def test_comments(self): class TestEntryHistory(TestCase): - routes = ["1.2.3.6/32", "2.4.6.0/24", "6.0.0.0/8"] + routes = ["192.0.2.16/32", "198.51.100.16/28"] def setUp(self): self.atype = ActionType.objects.create(name="Block") for r in self.routes: route = Route.objects.create(route=r) entry = Entry.objects.create(route=route, actiontype=self.atype) - create_reason = "Zeek detected a scan from 1.1.1.1." + create_reason = "Zeek detected a scan from 192.0.2.1." update_change_reason(entry, create_reason) self.assertEqual(entry.get_change_reason(), create_reason) @@ -31,12 +31,12 @@ def test_comments(self): for r in self.routes: route_old = Route.objects.get(route=r) e = Entry.objects.get(route=route_old) - self.assertEqual(e.get_change_reason(), "Zeek detected a scan from 1.1.1.1.") + self.assertEqual(e.get_change_reason(), "Zeek detected a scan from 192.0.2.1.") - route_new = str(route_old).replace("6", "7") + route_new = str(route_old).replace("16", "32") e.route = Route.objects.create(route=route_new) - change_reason = "I meant 7, not 6." + change_reason = "I meant 32, not 16." e._change_reason = change_reason e.save() diff --git a/scram/route_manager/tests/test_websockets.py b/scram/route_manager/tests/test_websockets.py new file mode 100644 index 00000000..8733ebf9 --- /dev/null +++ b/scram/route_manager/tests/test_websockets.py @@ -0,0 +1,186 @@ +import json +from asyncio import gather +from contextlib import asynccontextmanager + +from asgiref.sync import sync_to_async +from channels.routing import URLRouter +from channels.testing import WebsocketCommunicator +from django.contrib.auth import get_user_model +from django.test import TestCase +from django.urls import reverse + +from config.routing import websocket_urlpatterns +from scram.route_manager.models import ActionType, Client, WebSocketMessage, WebSocketSequenceElement + + +@asynccontextmanager +async def get_communicators(actiontypes, should_match, *args, **kwds): + """Creates a set of communicators, and then handles tear-down. + + Given two lists of the same length, a set of actiontypes, and set of boolean values, + creates that many communicators, one for each actiontype-bool pair. + + The boolean determines whether or not we're expecting to recieve a message to that communicator. + + Returns a list of (communicator, should_match bool) pairs. + """ + assert len(actiontypes) == len(should_match) + + router = URLRouter(websocket_urlpatterns) + communicators = [ + WebsocketCommunicator(router, f"/ws/route_manager/translator_{actiontype}/") for actiontype in actiontypes + ] + response = zip(communicators, should_match) + + for communicator, should_match in response: + connected, _ = await communicator.connect() + assert connected + + try: + yield response + + finally: + for communicator, should_match in response: + await communicator.disconnect() + + +class TestTranslatorBaseCase(TestCase): + """Base case that other test cases build on top of. Three translators in one group, test one v4 and one v6.""" + + def setUp(self): + # TODO: This is copied from test_api; should de-dupe this. + self.url = reverse("api:v1:entry-list") + self.superuser = get_user_model().objects.create_superuser("admin", "admin@example.net", "admintestpassword") + self.client.login(username="admin", password="admintestpassword") + self.uuid = "0e7e1cbd-7d73-4968-bc4b-ce3265dc2fd3" + + self.action_name = "block" + + self.actiontype, _ = ActionType.objects.get_or_create(name=self.action_name) + self.actiontype.save() + + self.authorized_client = Client.objects.create( + hostname="authorized_client.example.net", + uuid=self.uuid, + is_authorized=True, + ) + self.authorized_client.authorized_actiontypes.set([self.actiontype]) + + wsm, _ = WebSocketMessage.objects.get_or_create(msg_type="translator_add", msg_data_route_field="route") + _, _ = WebSocketSequenceElement.objects.get_or_create( + websocketmessage=wsm, verb="A", action_type=self.actiontype + ) + + # Set some defaults; some child classes override this + self.actiontypes = ["block"] * 3 + self.should_match = [True] * 3 + self.generate_add_msgs = [lambda ip, mask: {"type": "translator_add", "message": {"route": f"{ip}/{mask}"}}] + + # Now we run any local setup actions by the child classes + self.local_setUp() + + def local_setUp(self): + # Allow child classes to override this if desired + return + + async def get_messages(self, communicator, messages, should_match): + """Receive a number of messages from the WebSocket and validate them.""" + for msg in messages: + response = json.loads(await communicator.receive_from()) + match = response == msg + assert match == should_match + + async def get_nothings(self, communicator): + """Check there are no more messages waiting.""" + assert await communicator.receive_nothing(timeout=0.1, interval=0.01) is False + + async def add_ip(self, ip, mask): + async with get_communicators(self.actiontypes, self.should_match) as communicators: + await self.api_create_entry(ip) + + # A list of that many function calls to verify the response + get_message_func_calls = [ + self.get_messages(c, self.generate_add_msgs(ip, mask), should_match) + for c, should_match in communicators + ] + + # Turn our list into parameters to the function and await them all + await gather(*get_message_func_calls) + + await self.ensure_no_more_msgs(communicators) + + async def ensure_no_more_msgs(self, communicators): + """Run through all communicators and ensure they have no messages waiting.""" + get_nothing_func_calls = [self.get_nothings(c) for c, _ in communicators] + + # Ensure we don't receive any other messages + await gather(*get_nothing_func_calls) + + # Django ensures that the create is synchronous, so we have some extra steps to do + @sync_to_async + def api_create_entry(self, route): + return self.client.post( + self.url, + { + "route": route, + "comment": "test", + "uuid": self.uuid, + }, + format="json", + ) + + async def test_add_v4(self): + await self.add_ip("192.0.2.224", 32) + await self.add_ip("192.0.2.225", 32) + await self.add_ip("192.0.2.226", 32) + await self.add_ip("198.51.100.224", 32) + + async def test_add_v6(self): + await self.add_ip("2001:DB8:FDF0::", 128) + await self.add_ip("2001:DB8:FDF0::D", 128) + await self.add_ip("2001:DB8:FDF0::DB", 128) + await self.add_ip("2001:DB8:FDF0::DB8", 128) + + +class TranslatorDontCrossTheStreamsTestCase(TestTranslatorBaseCase): + """Two translators in the same group, two in another group, single IP, ensure we get only the messages we expect.""" + + def local_setUp(self): + self.actiontypes = ["block", "block", "noop", "noop"] + self.should_match = [True, True, False, False] + + +class TranslatorSequenceTestCase(TestTranslatorBaseCase): + """Test a sequence of WebSocket messages.""" + + def local_setUp(self): + wsm2 = WebSocketMessage.objects.create(msg_type="translator_add", msg_data_route_field="foo") + _ = WebSocketSequenceElement.objects.create( + websocketmessage=wsm2, verb="A", action_type=self.actiontype, order_num=20 + ) + wsm3 = WebSocketMessage.objects.create(msg_type="translator_add", msg_data_route_field="bar") + _ = WebSocketSequenceElement.objects.create( + websocketmessage=wsm3, verb="A", action_type=self.actiontype, order_num=2 + ) + + self.generate_add_msgs = [ + lambda ip, mask: {"type": "translator_add", "message": {"route": f"{ip}/{mask}"}}, # order_num=0 + lambda ip, mask: {"type": "translator_add", "message": {"bar": f"{ip}/{mask}"}}, # order_num=2 + lambda ip, mask: {"type": "translator_add", "message": {"foo": f"{ip}/{mask}"}}, # order_num=20 + ] + + +class TranslatorParametersTestCase(TestTranslatorBaseCase): + """Additional parameters in the JSONField.""" + + def local_setUp(self): + wsm = WebSocketMessage.objects.get(msg_type="translator_add", msg_data_route_field="route") + wsm.msg_data = {"asn": 65550, "community": 100, "route": "Ensure this gets overwritten."} + wsm.save() + + self.generate_add_msgs = [ + lambda ip, mask: { + "type": "translator_add", + "message": {"asn": 65550, "community": 100, "route": f"{ip}/{mask}"}, + } + ] diff --git a/translator/gobgp.py b/translator/gobgp.py index 30bf5652..4bec099e 100644 --- a/translator/gobgp.py +++ b/translator/gobgp.py @@ -53,7 +53,7 @@ def _build_path(self, ip): else: next_hop.Pack( attribute_pb2.NextHopAttribute( - next_hop="192.0.2.1", + next_hop="192.0.2.199", ) ) @@ -79,6 +79,11 @@ def add_path(self, ip): _TIMEOUT_SECONDS, ) + def del_all_paths(self): + logging.warning("Withdrawing ALL routes") + + self.stub.DeletePath(gobgp_pb2.DeletePathRequest(table_type=gobgp_pb2.GLOBAL), _TIMEOUT_SECONDS) + def del_path(self, ip): path = self._build_path(ip) diff --git a/translator/tests/acceptance/features/bgp.feature b/translator/tests/acceptance/features/bgp.feature index 5e7b9a46..e8c8f626 100644 --- a/translator/tests/acceptance/features/bgp.feature +++ b/translator/tests/acceptance/features/bgp.feature @@ -2,21 +2,21 @@ Feature: block with BGP Users can block routes via BGP Scenario: We can block a v4 IP - When we add 1.2.3.4/32 to the block list - Then 1.2.3.4 is blocked - And 1.2.3.5 is unblocked + When we add 192.0.2.4/32 to the block list + Then 192.0.2.4 is blocked + And 192.0.2.5 is unblocked Scenario: We can block a v6 IP - When we add f00::/64 to the block list - Then f00:: is blocked + When we add 2001:DB8:A::/64 to the block list + Then 2001:DB8:A:: is blocked And baba:: is unblocked Scenario: We can block, then unblock a v4 IP - When we add 1.2.3.4/32 to the block list - And we delete 1.2.3.4/32 from the block list - Then 1.2.3.4 is unblocked + When we add 192.0.2.4/32 to the block list + And we delete 192.0.2.4/32 from the block list + Then 192.0.2.4 is unblocked Scenario: We can block, then unblock a v6 IP - When we add f00::/64 to the block list - And we delete f00::/64 from the block list - Then f00:: is unblocked + When we add 2001:DB8:B::/64 to the block list + And we delete 2001:DB8:B::/64 from the block list + Then 2001:DB8:B:: is unblocked diff --git a/translator/translator.py b/translator/translator.py index 49b5351a..45641734 100644 --- a/translator/translator.py +++ b/translator/translator.py @@ -22,8 +22,16 @@ async def main(): json_message = json.loads(message) event_type = json_message.get("type") event_data = json_message.get("message") - if event_type not in ["translator_add", "translator_remove", "translator_check"]: + if event_type not in [ + "translator_add", + "translator_remove", + "translator_remove_all", + "translator_check", + ]: logging.error(f"Unknown event type received: {event_type!r}") + # TODO: Maybe only allow this in testing? + elif event_type == "translator_remove_all": + g.del_all_paths() else: try: ip = ipaddress.ip_interface(event_data["route"])