diff --git a/gns3server/api/routes/controller/links.py b/gns3server/api/routes/controller/links.py index b7fa2b40d..923c73c25 100644 --- a/gns3server/api/routes/controller/links.py +++ b/gns3server/api/routes/controller/links.py @@ -123,8 +123,13 @@ async def create_link(project_id: UUID, link_create: schemas.LinkCreate) -> sche return link.asdict() -@router.get("/{link_id}/available_filters", dependencies=[Depends(has_privilege("Link.Audit"))]) -async def get_filters(link: Link = Depends(dep_link)) -> List[dict]: +@router.get( + "/{link_id}/available_filters", + response_model=List[schemas.LinkFilterDefinition], + response_model_exclude_unset=True, + dependencies=[Depends(has_privilege("Link.Audit"))], +) +async def get_filters(link: Link = Depends(dep_link)) -> List[schemas.LinkFilterDefinition]: """ Return all filters available for a given link. diff --git a/gns3server/schemas/__init__.py b/gns3server/schemas/__init__.py index a5eb8406c..056473e0a 100644 --- a/gns3server/schemas/__init__.py +++ b/gns3server/schemas/__init__.py @@ -37,6 +37,10 @@ from .controller.links import ( Link, LinkCapture, LinkCreate, + LinkFilterDefinition, + LinkFilterParameter, + LinkFilters, + LinkFilterType, LinkUpdate, MarkerCreate, MarkerDefinitionCreate, @@ -244,6 +248,10 @@ __all__ = [ "Link", "LinkCapture", "LinkCreate", + "LinkFilterDefinition", + "LinkFilterParameter", + "LinkFilterType", + "LinkFilters", "LinkUpdate", "LoggedInUserUpdate", "MarkerCreate", diff --git a/gns3server/schemas/controller/links.py b/gns3server/schemas/controller/links.py index ccb85d424..88210b556 100644 --- a/gns3server/schemas/controller/links.py +++ b/gns3server/schemas/controller/links.py @@ -15,10 +15,10 @@ # along with this program. If not, see . from enum import Enum -from typing import List, Optional, Tuple +from typing import Annotated, List, Literal, Optional, Tuple from uuid import UUID, uuid4 -from pydantic import BaseModel, Field, field_validator +from pydantic import BaseModel, ConfigDict, Field, field_validator from ..update import PartialUpdateModel from .labels import Label @@ -54,6 +54,77 @@ class LinkStyle(BaseModel): control_offset: Optional[Tuple[float, float]] = None +class LinkFilterType(str, Enum): + """ + Packet filter type. + """ + + frequency_drop = "frequency_drop" + packet_loss = "packet_loss" + delay = "delay" + corrupt = "corrupt" + bpf = "bpf" + + +class LinkFilters(BaseModel): + """ + Packet filters applied on a link. Each filter is an array of positional values. + A filter set to an empty array or to its disabled value (0, 0 for delay, empty text for bpf) is not applied. + """ + + model_config = ConfigDict(extra="forbid") + + frequency_drop: Optional[List[Annotated[int, Field(ge=-1, le=32767)]]] = Field( + None, + max_length=1, + description="[frequency]: -1 drops every packet, N > 0 drops every Nth packet, 0 disables the filter", + ) + packet_loss: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field( + None, + max_length=1, + description="[chance]: percentage chance for a packet to be lost, 0 disables the filter", + ) + delay: Optional[List[Annotated[int, Field(ge=0, le=32767)]]] = Field( + None, + max_length=2, + description="[latency, jitter] in milliseconds: latency must be 1 to 32767 unless both values are 0, " + "which disables the filter; jitter is 0 to 32767", + ) + corrupt: Optional[List[Annotated[int, Field(ge=0, le=100)]]] = Field( + None, + max_length=1, + description="[chance]: percentage chance for a packet to be corrupted, 0 disables the filter", + ) + bpf: Optional[List[str]] = Field( + None, + max_length=1, + description="[expressions]: BPF expressions, one per line, matching packets are dropped", + ) + + +class LinkFilterParameter(BaseModel): + """ + Parameter of a packet filter. + """ + + name: str + type: Literal["int", "text"] + minimum: Optional[int] = None + maximum: Optional[int] = None + unit: Optional[str] = None + + +class LinkFilterDefinition(BaseModel): + """ + Packet filter available on a link. + """ + + type: LinkFilterType + name: str + description: str + parameters: List[LinkFilterParameter] + + class LinkBase(BaseModel): """ Link data. @@ -62,7 +133,7 @@ class LinkBase(BaseModel): nodes: Optional[List[LinkNode]] = Field(None, min_length=0, max_length=2) suspend: Optional[bool] = None link_style: Optional[LinkStyle] = None - filters: Optional[dict] = None + filters: Optional[LinkFilters] = None markers: Optional[dict] = Field( None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}" ) diff --git a/gns3server/schemas/controller/topology.py b/gns3server/schemas/controller/topology.py index 0c4f39489..807ac7f4a 100644 --- a/gns3server/schemas/controller/topology.py +++ b/gns3server/schemas/controller/topology.py @@ -32,6 +32,11 @@ from .nodes import Node from .projects import Supplier, Variable +class TopologyLink(Link): + # Legacy topology files store filters as untyped dicts + filters: Optional[dict] = None # type: ignore[assignment] + + class TopologyType(str, Enum): topology = "topology" @@ -39,7 +44,7 @@ class TopologyType(str, Enum): class TopologyContent(BaseModel): computes: List[Compute] = Field(..., description="List of computes") drawings: List[Drawing] = Field(..., description="List of drawings") - links: List[Link] = Field(..., description="List of links") + links: List[TopologyLink] = Field(..., description="List of links") nodes: List[Node] = Field(..., description="List of nodes") diff --git a/tests/api/routes/controller/test_links.py b/tests/api/routes/controller/test_links.py index 518eb22f9..1579c4808 100644 --- a/tests/api/routes/controller/test_links.py +++ b/tests/api/routes/controller/test_links.py @@ -335,6 +335,97 @@ class TestLinkRoutes: assert response.status_code == status.HTTP_200_OK assert response.json() == FILTERS + async def test_update_link_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + filters = { + "frequency_drop": [50], + "packet_loss": [10], + "delay": [10, 5], + "corrupt": [3], + "bpf": ["icmp"], + } + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters} + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["filters"] == filters + assert link.filters == filters + + async def test_update_link_disabled_filters(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), + json={"filters": {"delay": [0, 0], "packet_loss": [0], "corrupt": [], "bpf": [""]}}, + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["filters"] == {} + + @pytest.mark.parametrize( + "filters", + [ + {"packet_loss": [101]}, + {"packet_loss": [-1]}, + {"corrupt": [101]}, + {"frequency_drop": [-2]}, + {"frequency_drop": [32768]}, + {"delay": [32768, 0]}, + {"delay": [10, -1]}, + {"packet_loss": [1, 2]}, + {"bpf": [1]}, + {"packet_loss": ["abc"]}, + ], + ) + async def test_update_link_invalid_filter_values( + self, app: FastAPI, client: AsyncClient, project: Project, filters: dict + ) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": filters} + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert "message" in response.json() + assert link.filters == {} + + async def test_update_link_unknown_filter_type(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), + json={"filters": {"unknown": [1]}}, + ) + assert response.status_code == status.HTTP_422_UNPROCESSABLE_CONTENT + assert "message" in response.json() + assert link.filters == {} + + async def test_update_link_invalid_delay_latency(self, app: FastAPI, client: AsyncClient, project: Project) -> None: + + link = Link(project) + project._links = {link.id: link} + response = await client.put( + app.url_path_for("update_link", project_id=project.id, link_id=link.id), json={"filters": {"delay": [0, 5]}} + ) + assert response.status_code == status.HTTP_409_CONFLICT + + async def test_openapi_link_filters(self, app: FastAPI) -> None: + + schemas = app.openapi()["components"]["schemas"] + link_filters = schemas["LinkFilters"] + assert link_filters["additionalProperties"] is False + assert set(link_filters["properties"]) == {"frequency_drop", "packet_loss", "delay", "corrupt", "bpf"} + assert schemas["LinkFilterDefinition"]["properties"]["parameters"]["items"] == { + "$ref": "#/components/schemas/LinkFilterParameter" + } + path = "/v3/projects/{project_id}/links/{link_id}/available_filters" + responses = app.openapi()["paths"][path]["get"]["responses"]["200"]["content"]["application/json"]["schema"] + assert responses["items"] == {"$ref": "#/components/schemas/LinkFilterDefinition"} + async def test_get_udp_interface(self, app: FastAPI, client: AsyncClient, project: Project) -> None: """ Test getting UDP tunnel interface information from a link.