From 0f8d31c72c02b16a594184e2d9b792162c21f04a Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 28 Jul 2023 14:41:39 +0100 Subject: [PATCH 01/21] #1711 - Last minute docs changes --- docs/index.rst | 9 ++++----- docs/source/primaite_session.rst | 28 ++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 5 deletions(-) diff --git a/docs/index.rst b/docs/index.rst index 3b1a13ec..2c7d4690 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -6,7 +6,7 @@ Welcome to PrimAITE's documentation ==================================== What is PrimAITE? ------------------------- +----------------- PrimAITE (Primary-level AI Training Environment) is a simulation environment for training AI under the ARCD programme. It incorporates the functionality required of a Primary-level environment, as specified in the Dstl ARCD Training Environment Matrix document: @@ -14,10 +14,9 @@ PrimAITE (Primary-level AI Training Environment) is a simulation environment for * The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, traffic loading, operating systems, file system, services and processes; * Operates at machine-speed to enable fast training cycles. -PrimAITE aims to evolve into an ARCD environment that could be used as the follow-on from Reception level approaches (e.g. `Yawning-Titan `_), and help bridge the Sim-to-Real gap into Secondary level environments. What is PrimAITE built with --------------------------------------- +--------------------------- * `OpenAI's Gym `_ is used as the basis for AI blue agent interaction with the PrimAITE environment * `Networkx `_ is used as the underlying data structure used for the PrimAITE environment @@ -29,8 +28,8 @@ What is PrimAITE built with * `Plotly `_ is used for building high level charts -Where next? ------------- +Getting Started with PrimAITE +----------------------------- Head over to the :ref:`getting-started` page to install and setup PrimAITE! diff --git a/docs/source/primaite_session.rst b/docs/source/primaite_session.rst index ed023499..15ba9f4c 100644 --- a/docs/source/primaite_session.rst +++ b/docs/source/primaite_session.rst @@ -49,6 +49,34 @@ For example, when running a session at 17:30:00 on 31st January 2023, the sessio ``primaite session`` can be ran in the terminal/command prompt without arguments. It will use the default configs in the directory ``primaite/config/example_config``. +To run a PrimAITE session using legacy training or laydown config files, add the ``--legacy-tc`` and/or ``legacy-ldc`` options. + +.. tabs:: + + .. code-tab:: bash + :caption: Unix CLI + + cd ~/primaite/2.0.0 + source ./.venv/bin/activate + primaite session --tc ./config/my_legacy_training_config.yaml --legacy-tc --ldc ./config/my_legacy_lay_down_config.yaml --legacy-ldc + + .. code-tab:: powershell + :caption: Powershell CLI + + cd ~\primaite\2.0.0 + .\.venv\Scripts\activate + primaite session --tc .\config\my_legacy_training_config.yaml --legacy-tc --ldc .\config\my_legacy_lay_down_config.yaml --legacy-ldc + + + .. code-tab:: python + :caption: Python + + from primaite.main import run + + training_config = + lay_down_config = + run(training_config, lay_down_config, legacy_training_config=True, legacy_lay_down_config=True) + Outputs ------- From 61fe8c20313727504324fd3b455242248e441cda Mon Sep 17 00:00:00 2001 From: jamesshort1 <107395948+jamesshort1@users.noreply.github.com> Date: Mon, 31 Jul 2023 09:16:24 +0100 Subject: [PATCH 02/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9a4ff749..723a7a27 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The ARCD Primary-level AI Training Environment (**PrimAITE**) provides an effect - The ability to model a relevant platform / system context; -- The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, traffic loading, operating systems, services and processes; +- The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, traffic loading, operating systems and services; - Operates at machine-speed to enable fast training cycles. From 80ddf511dc6e79d47a3106f5789053a0f2a30bb8 Mon Sep 17 00:00:00 2001 From: jamesshort1 <107395948+jamesshort1@users.noreply.github.com> Date: Mon, 31 Jul 2023 09:17:48 +0100 Subject: [PATCH 03/21] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 723a7a27..3913a3d1 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ PrimAITE presents the following features: - Application of IERs to the platform / system laydown adheres to the ACL ruleset; -- Presents an OpenAI gym or RLLib interface to the environment, allowing integration with any OpenAI gym compliant defensive agents; +- Presents an OpenAI gym or RLLib interface to the environment, allowing integration with any compliant defensive agents; - Full capture of discrete logs relating to agent training (full system state, agent actions taken, instantaneous and average reward for every step of every episode); From fed65db7fc3ec6c8fc1374a95797b75ec62b5788 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 3 Aug 2023 16:04:23 +0100 Subject: [PATCH 04/21] Updated the What is PrimAITE? section in index.rst. Dropped the use of sphinx-code-tabs in the docs as building the docs in pdf (make latexpdf) is suddenly complaining about the tab buttons. --- docs/conf.py | 1 - docs/index.rst | 65 ++++++++++++++++-- docs/source/getting_started.rst | 114 +++++++++++++++---------------- docs/source/primaite_session.rst | 86 +++++++++++------------ pyproject.toml | 1 - 5 files changed, 159 insertions(+), 108 deletions(-) diff --git a/docs/conf.py b/docs/conf.py index 4a805916..efd60b49 100644 --- a/docs/conf.py +++ b/docs/conf.py @@ -43,7 +43,6 @@ extensions = [ "sphinx.ext.viewcode", # Add a link to the Python source code for classes, functions etc. "sphinx.ext.todo", "sphinx_copybutton", # Adds a copy button to code blocks - "sphinx_code_tabs", # Enables tabbed code blocks ] diff --git a/docs/index.rst b/docs/index.rst index 2c7d4690..b2c5cfaa 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -8,11 +8,68 @@ Welcome to PrimAITE's documentation What is PrimAITE? ----------------- -PrimAITE (Primary-level AI Training Environment) is a simulation environment for training AI under the ARCD programme. It incorporates the functionality required of a Primary-level environment, as specified in the Dstl ARCD Training Environment Matrix document: +Overview +^^^^^^^^ + +The ARCD Primary-level AI Training Environment (**PrimAITE**) provides an effective simulation capability for the purposes of training and evaluating AI in a cyber-defensive role. It incorporates the functionality required of a primary-level ARCD environment, which includes: + +- The ability to model a relevant platform / system context; +- Modelling an adversarial agent that the defensive agent can be trained and evaluated against; +- The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, operating systems, services and traffic loading on links; +- Modelling background pattern-of-life; +- Operates at machine-speed to enable fast training cycles. + +Features +^^^^^^^^ + +PrimAITE incorporates the following features: + +- Highly configurable (via YAML files) to provide the means to model a variety of platform / system laydowns and adversarial attack scenarios; +- A Reinforcement Learning (RL) reward function based on (a) the ability to counter the modelled adversarial cyber-attack, and (b) the ability to ensure success; +- Provision of logging to support AI performance / effectiveness assessment; +- Uses the concept of Information Exchange Requirements (IERs) to model background pattern of life and adversarial behaviour; +- An Access Control List (ACL) function, mimicking the behaviour of a network firewall, is applied across the model, following standard ACL rule format (e.g. DENY/ALLOW, source IP address, destination IP address, protocol and port); +- Application of traffic to the links of the platform / system laydown adheres to the ACL ruleset; +- Presents both an OpenAI gym and Ray RLLib interface to the environment, allowing integration with any compliant defensive agents; +- Allows for the saving and loading of trained defensive agents; +- Stochastic adversarial agent behaviour; +- Full capture of discrete logs relating to agent training or evaluation (system state, agent actions taken, instantaneous and average reward for every step of every episode); +- Distinct control over running a training and / or evaluation session; +- NetworkX provides laydown visualisation capability. + +Architecture +^^^^^^^^^^^^ + +PrimAITE is a Python application and is therefore Operating System agnostic. The OpenAI gym and Ray RLLib frameworks are employed to provide an interface and source for AI agents. Configuration of PrimAITE is achieved via included YAML files which support full control over the platform / system laydown being modelled, background pattern of life, adversarial (red agent) behaviour, and step and episode count. NetworkX based nodes and links host Python classes to present attributes and methods, and hence a more representative platform / system can be modelled within the simulation. + + + +Training & Evaluation Capability +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PrimAITE provides a training and evaluation capability to AI agents in the context of cyber-attack, via its OpenAI Gym and RLLib compliant interface. Scenarios can be constructed to reflect platform / system laydowns consisting of any configuration of nodes (e.g. PCs, servers, switches etc.) and network links between them. All nodes can be configured to model services (and their status) and the traffic loading between them over the network links. Traffic loading is broken down into a per service granularity, relating directly to a protocol (e.g. Service A would be configured as a TCP service, and TCP traffic then flows between instances of Service A under the direction of a tailored IER). Highlights of PrimAITE’s training and evaluation capability are: + +- The scenario is not bound to a representation of any platform, system, or technology; +- Fully configurable (network / system laydown, IERs, node pattern-of-life, ACL, number of episodes, steps per episode) and repeatable to suit the requirements of AI agents; +- Can integrate with any OpenAI Gym or RLLib compliant AI agent. + +Use of PrimAITE default scenarios within ARCD is supported by a “Use Case Profile” tailored to the scenario. + +AI Assessment Capability +^^^^^^^^^^^^^^^^^^^^^^^^ + +PrimAITE includes the capability to support in-depth assessment of cyber defence AI by outputting logs of the environment state and AI behaviour throughout both training and evaluation sessions. These logs include the following data: + +- Timestamp; +- Episode and step number; +- Agent identifier; +- Observation space; +- Action taken (by defensive AI); +- Reward value. + +Logs are available in CSV format and provide coverage of the above data for every step of every episode. + -* The ability to model a relevant platform / system context; -* The ability to model key characteristics of a platform / system by representing connections, IP addresses, ports, traffic loading, operating systems, file system, services and processes; -* Operates at machine-speed to enable fast training cycles. What is PrimAITE built with diff --git a/docs/source/getting_started.rst b/docs/source/getting_started.rst index f07f1d27..1dbf9dec 100644 --- a/docs/source/getting_started.rst +++ b/docs/source/getting_started.rst @@ -14,20 +14,19 @@ Pre-Requisites In order to get **PrimAITE** installed, you will need to have a python version between 3.8 and 3.10 installed. If you don't already have it, this is how to install it: -.. tabs:: lang +.. code-block:: bash + :caption: Unix - .. code-tab:: bash - :caption: Unix + sudo add-apt-repository ppa:deadsnakes/ppa + sudo apt install python3.10 + sudo apt-get install python3-pip + sudo apt-get install python3-venv - sudo add-apt-repository ppa:deadsnakes/ppa - sudo apt install python3.10 - sudo apt-get install python3-pip - sudo apt-get install python3-venv - .. code-tab:: text - :caption: Windows (Powershell) +.. code-block:: text + :caption: Windows (Powershell) - - Manual install from: https://www.python.org/downloads/release/python-31011/ + - Manual install from: https://www.python.org/downloads/release/python-31011/ **PrimAITE** is designed to be OS-agnostic, and thus should work on most variations/distros of Linux, Windows, and MacOS. @@ -36,30 +35,30 @@ Install PrimAITE 1. Create a primaite directory in your home directory: -.. tabs:: lang - .. code-tab:: bash - :caption: Unix - mkdir ~/primaite/2.0.0 +.. code-block:: bash + :caption: Unix - .. code-tab:: powershell - :caption: Windows (Powershell) + mkdir ~/primaite/2.0.0 - mkdir ~\primaite\2.0.0 +.. code-block:: powershell + :caption: Windows (Powershell) + + mkdir ~\primaite\2.0.0 2. Navigate to the primaite directory and create a new python virtual environment (venv) -.. tabs:: lang - .. code-tab:: bash - :caption: Unix - cd ~/primaite/2.0.0 - python3 -m venv .venv +.. code-block:: bash + :caption: Unix - .. code-tab:: powershell - :caption: Windows (Powershell) + cd ~/primaite/2.0.0 + python3 -m venv .venv + +.. code-block:: powershell + :caption: Windows (Powershell) cd ~\primaite\2.0.0 python3 -m venv .venv @@ -67,44 +66,41 @@ Install PrimAITE 3. Activate the venv -.. tabs:: lang - .. code-tab:: bash - :caption: Unix +.. code-block:: bash + :caption: Unix - source .venv/bin/activate + source .venv/bin/activate - .. code-tab:: powershell - :caption: Windows (Powershell) +.. code-block:: powershell + :caption: Windows (Powershell) - .\.venv\Scripts\activate + .\.venv\Scripts\activate 4. Install PrimAITE using pip from PyPi -.. tabs:: lang - .. code-tab:: bash - :caption: Unix +.. code-block:: bash + :caption: Unix - pip install primaite + pip install primaite - .. code-tab:: powershell - :caption: Windows (Powershell) +.. code-block:: powershell + :caption: Windows (Powershell) - pip install primaite + pip install primaite 5. Perform the PrimAITE setup -.. tabs:: lang - .. code-tab:: bash - :caption: Unix +.. code-block:: bash + :caption: Unix - primaite setup + primaite setup - .. code-tab:: powershell - :caption: Windows (Powershell) +.. code-block:: powershell + :caption: Windows (Powershell) primaite setup @@ -123,33 +119,31 @@ of your choice: Create and activate your Python virtual environment (venv) -.. tabs:: lang - .. code-tab:: bash - :caption: Unix +.. code-block:: bash + :caption: Unix - python3 -m venv venv - source venv/bin/activate + python3 -m venv venv + source venv/bin/activate - .. code-tab:: powershell - :caption: Windows (Powershell) +.. code-block:: powershell + :caption: Windows (Powershell) - python3 -m venv venv - .\venv\Scripts\activate + python3 -m venv venv + .\venv\Scripts\activate Install PrimAITE with the dev extra -.. tabs:: lang - .. code-tab:: bash - :caption: Unix +.. code-block:: bash + :caption: Unix - pip install -e .[dev] + pip install -e .[dev] - .. code-tab:: powershell - :caption: Windows (Powershell) +.. code-block:: powershell + :caption: Windows (Powershell) - pip install -e .[dev] + pip install -e .[dev] To view the complete list of packages installed during PrimAITE installation, go to the dependencies page (:ref:`Dependencies`). diff --git a/docs/source/primaite_session.rst b/docs/source/primaite_session.rst index 15ba9f4c..8ccc9070 100644 --- a/docs/source/primaite_session.rst +++ b/docs/source/primaite_session.rst @@ -15,31 +15,31 @@ A PrimAITE session can be ran either with the ``primaite session`` command from Both the ``primaite session`` and :func:`primaite.main.run` take a training config and a lay down config as parameters. -.. tabs:: - - .. code-tab:: bash - :caption: Unix CLI - - cd ~/primaite/2.0.0 - source ./.venv/bin/activate - primaite session --tc ./config/my_training_config.yaml --ldc ./config/my_lay_down_config.yaml - - .. code-tab:: powershell - :caption: Powershell CLI - - cd ~\primaite\2.0.0 - .\.venv\Scripts\activate - primaite session --tc .\config\my_training_config.yaml --ldc .\config\my_lay_down_config.yaml - .. code-tab:: python - :caption: Python +.. code-block:: bash + :caption: Unix CLI - from primaite.main import run + cd ~/primaite/2.0.0 + source ./.venv/bin/activate + primaite session --tc ./config/my_training_config.yaml --ldc ./config/my_lay_down_config.yaml - training_config = - lay_down_config = - run(training_config, lay_down_config) +.. code-block:: powershell + :caption: Powershell CLI + + cd ~\primaite\2.0.0 + .\.venv\Scripts\activate + primaite session --tc .\config\my_training_config.yaml --ldc .\config\my_lay_down_config.yaml + + +.. code-block:: python + :caption: Python + + from primaite.main import run + + training_config = + lay_down_config = + run(training_config, lay_down_config) When a session is ran, a session output sub-directory is created in the users app sessions directory (``~/primaite/2.0.0/sessions``). The sub-directory is formatted as such: ``~/primaite/2.0.0/sessions//_/`` @@ -51,31 +51,33 @@ For example, when running a session at 17:30:00 on 31st January 2023, the sessio To run a PrimAITE session using legacy training or laydown config files, add the ``--legacy-tc`` and/or ``legacy-ldc`` options. -.. tabs:: - - .. code-tab:: bash - :caption: Unix CLI - - cd ~/primaite/2.0.0 - source ./.venv/bin/activate - primaite session --tc ./config/my_legacy_training_config.yaml --legacy-tc --ldc ./config/my_legacy_lay_down_config.yaml --legacy-ldc - - .. code-tab:: powershell - :caption: Powershell CLI - - cd ~\primaite\2.0.0 - .\.venv\Scripts\activate - primaite session --tc .\config\my_legacy_training_config.yaml --legacy-tc --ldc .\config\my_legacy_lay_down_config.yaml --legacy-ldc - .. code-tab:: python - :caption: Python +.. code-block:: bash + :caption: Unix CLI + + cd ~/primaite/2.0.0 + source ./.venv/bin/activate + primaite session --tc ./config/my_legacy_training_config.yaml --legacy-tc --ldc ./config/my_legacy_lay_down_config.yaml --legacy-ldc + +.. code-block:: powershell + :caption: Powershell CLI + + cd ~\primaite\2.0.0 + .\.venv\Scripts\activate + primaite session --tc .\config\my_legacy_training_config.yaml --legacy-tc --ldc .\config\my_legacy_lay_down_config.yaml --legacy-ldc + + +.. code-block:: python + :caption: Python + + from primaite.main import run + + training_config = + lay_down_config = + run(training_config, lay_down_config, legacy_training_config=True, legacy_lay_down_config=True) - from primaite.main import run - training_config = - lay_down_config = - run(training_config, lay_down_config, legacy_training_config=True, legacy_lay_down_config=True) Outputs diff --git a/pyproject.toml b/pyproject.toml index b66b0168..5a28eefd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -65,7 +65,6 @@ dev = [ "pytest-flake8==1.1.1", "setuptools==66", "Sphinx==6.1.3", - "sphinx-code-tabs==0.5.3", "sphinx-copybutton==0.5.2", "wheel==0.38.4" ] From 01fb9e65fec7d4a9a9b3bbd1bc23620e67b67c32 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 15 Aug 2023 11:14:23 +0100 Subject: [PATCH 05/21] Added the DSTL MIT license and updated the license in pyproject.toml --- LICENSE | 15 ++++----------- pyproject.toml | 2 +- 2 files changed, 5 insertions(+), 12 deletions(-) diff --git a/LICENSE b/LICENSE index 3f5e4bb3..93d6f98b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,28 +1,21 @@ -MIT License License +MIT License -MIT License Conditions +Copyright (c) 2023 - 2025 Defence Science and Technology Laboratory UK (https://dstl.gov.uk) -These MIT License conditions confirm the provision of the following artefacts as MIT License by Defence Science and Technology +Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights - to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE - AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER - LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. - - - - diff --git a/pyproject.toml b/pyproject.toml index 5a28eefd..229d29d5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -10,7 +10,7 @@ license = {file = "LICENSE"} requires-python = ">=3.8, <3.11" dynamic = ["version", "readme"] classifiers = [ - "License :: MIT License", + "License :: OSI Approved :: MIT License", "Development Status :: 5 - Production/Stable", "Operating System :: Microsoft :: Windows", "Operating System :: MacOS", From 1a1c3c9344c8089316bc69f8748b068a1ba73c0a Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 15 Aug 2023 11:26:15 +0100 Subject: [PATCH 06/21] Added sphinx docs build pipeline for GitHub pages on release --- .github/workflows/build-sphinx.yml | 60 ++++++++++++++++++++ docs/build-sphinx-docs-to-github-pages.sh | 67 +++++++++++++++++++++++ 2 files changed, 127 insertions(+) create mode 100644 .github/workflows/build-sphinx.yml create mode 100644 docs/build-sphinx-docs-to-github-pages.sh diff --git a/.github/workflows/build-sphinx.yml b/.github/workflows/build-sphinx.yml new file mode 100644 index 00000000..82da1c6b --- /dev/null +++ b/.github/workflows/build-sphinx.yml @@ -0,0 +1,60 @@ +name: build-sphinx-to-github-pages + +env: + GITHUB_ACTOR: Autonomous-Resilient-Cyber-Defence + GITHUB_REPOSITORY: Autonomous-Resilient-Cyber-Defence/PrimAITE + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN}} + +on: + push: + branches: [main] + + +jobs: + build: + + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.10"] + + steps: + - uses: actions/checkout@v3 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + + - name: Install python dev + run: | + set -x + sudo apt-get update + sudo add-apt-repository ppa:deadsnakes/ppa -y + sudo apt install python${{ matrix.python-version}}-dev -y + + - name: Install Git + run: | + set -x + sudo apt-get install -y git + shell: bash + + - name: Set pip, wheel, setuptools versions + run: | + python -m pip install --upgrade pip==23.0.1 + pip install wheel==0.38.4 --upgrade + pip install setuptools==66 --upgrade + pip install build + + - name: Install PrimAITE for docs autosummary + run: | + set -x + python -m pip install -e .[dev] + + - name: Run build script for Sphinx pages + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + + run: | + set -x + bash $PWD/docs/build-sphinx-docs-to-github-pages.sh diff --git a/docs/build-sphinx-docs-to-github-pages.sh b/docs/build-sphinx-docs-to-github-pages.sh new file mode 100644 index 00000000..f1d40647 --- /dev/null +++ b/docs/build-sphinx-docs-to-github-pages.sh @@ -0,0 +1,67 @@ +#!/bin/bash +set -x + +apt-get update +apt-get -y install git rsync python3-sphinx + +pwd ls -lah +export SOURCE_DATE_EPOCH=$(git log -1 --pretty=%ct) + +############## +# BUILD DOCS # +############## + +cd docs +# Python Sphinx, configured with source/conf.py +# See https://www.sphinx-doc.org/ +make clean +make html + +cd .. +####################### +# Update GitHub Pages # +####################### + +git config --global user.name "${GITHUB_ACTOR}" +git config --global user.email "${GITHUB_ACTOR}@users.noreply.github.com" + +docroot=`mktemp -d` + +rsync -av $PWD/docs/_build/html/ "${docroot}/" + +pushd "${docroot}" + +git init +git remote add deploy "https://token:${GITHUB_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" +git checkout -b sphinx-docs-github-pages + +# Adds .nojekyll file to the root to signal to GitHub that +# directories that start with an underscore (_) can remain +touch .nojekyll + +# Add README +cat > README.md < Date: Tue, 15 Aug 2023 13:23:25 +0100 Subject: [PATCH 07/21] Create spinx-pipeline --- .github/workflows/spinx-pipeline | 36 ++++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 .github/workflows/spinx-pipeline diff --git a/.github/workflows/spinx-pipeline b/.github/workflows/spinx-pipeline new file mode 100644 index 00000000..22f666b0 --- /dev/null +++ b/.github/workflows/spinx-pipeline @@ -0,0 +1,36 @@ +# This is a basic workflow to help you get started with Actions + +name: CI + +# Controls when the workflow will run +on: + # Triggers the workflow on push or pull request events but only for the "dev" branch + push: + branches: [ "dev" ] + pull_request: + branches: [ "dev" ] + + # Allows you to run this workflow manually from the Actions tab + workflow_dispatch: + +# A workflow run is made up of one or more jobs that can run sequentially or in parallel +jobs: + # This workflow contains a single job called "build" + build: + # The type of runner that the job will run on + runs-on: ubuntu-latest + + # Steps represent a sequence of tasks that will be executed as part of the job + steps: + # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it + - uses: actions/checkout@v3 + + # Runs a single command using the runners shell + - name: Run a one-line script + run: echo Hello, world! + + # Runs a set of commands using the runners shell + - name: Run a multi-line script + run: | + echo Add other actions to build, + echo test, and deploy your project. From 58048cd0e1c05ce43464e1d2c2e62855a2c4fa3a Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 15 Aug 2023 13:28:02 +0100 Subject: [PATCH 08/21] Dropped sphinx-pipeline --- .github/workflows/spinx-pipeline | 36 -------------------------------- 1 file changed, 36 deletions(-) delete mode 100644 .github/workflows/spinx-pipeline diff --git a/.github/workflows/spinx-pipeline b/.github/workflows/spinx-pipeline deleted file mode 100644 index 22f666b0..00000000 --- a/.github/workflows/spinx-pipeline +++ /dev/null @@ -1,36 +0,0 @@ -# This is a basic workflow to help you get started with Actions - -name: CI - -# Controls when the workflow will run -on: - # Triggers the workflow on push or pull request events but only for the "dev" branch - push: - branches: [ "dev" ] - pull_request: - branches: [ "dev" ] - - # Allows you to run this workflow manually from the Actions tab - workflow_dispatch: - -# A workflow run is made up of one or more jobs that can run sequentially or in parallel -jobs: - # This workflow contains a single job called "build" - build: - # The type of runner that the job will run on - runs-on: ubuntu-latest - - # Steps represent a sequence of tasks that will be executed as part of the job - steps: - # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it - - uses: actions/checkout@v3 - - # Runs a single command using the runners shell - - name: Run a one-line script - run: echo Hello, world! - - # Runs a set of commands using the runners shell - - name: Run a multi-line script - run: | - echo Add other actions to build, - echo test, and deploy your project. From 18f57d64187a94a8b689cbf091c2b1176240703b Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Tue, 15 Aug 2023 13:56:06 +0100 Subject: [PATCH 09/21] Added PrimAITE_logo_transparent.png file --- PrimAITE_logo_transparent.png | Bin 0 -> 280955 bytes README.md | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) create mode 100644 PrimAITE_logo_transparent.png diff --git a/PrimAITE_logo_transparent.png b/PrimAITE_logo_transparent.png new file mode 100644 index 0000000000000000000000000000000000000000..3e12f643adfda0b1297b306dd8bf92a8fce610f6 GIT binary patch literal 280955 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%0ACp7KFP{2?xUKHOmktqA|Y9Op%Wy3X!!U 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�_si0R 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

SKMwT1o(! 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 Date: Fri, 25 Aug 2023 09:07:32 +0100 Subject: [PATCH 10/21] #1800 - Fixed the ping functionality so that it actually checks for replies and returns True if the right number of replies have been received. - Added the foundations of a Router class along with ACLRule and RouteTableEntry classes. --- .../simulator/network/hardware/base.py | 25 ++++-- .../network/hardware/nodes/router.py | 86 +++++++++++++++++++ .../network/test_frame_transmission.py | 4 +- .../integration_tests/network/test_routing.py | 27 ++++++ 4 files changed, 134 insertions(+), 8 deletions(-) create mode 100644 src/primaite/simulator/network/hardware/nodes/router.py create mode 100644 tests/integration_tests/network/test_routing.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 28e7693a..c64b9b67 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -173,6 +173,9 @@ class NIC(SimComponent): if self.connected_node.operating_state != NodeOperatingState.ON: self.connected_node.sys_log.error(f"NIC {self} cannot be enabled as the endpoint is not turned on") return + if not self.connected_link: + _LOGGER.error(f"NIC {self} cannot be enabled as it is not connected to a Link") + return self.enabled = True self.connected_node.sys_log.info(f"NIC {self} enabled") @@ -210,6 +213,7 @@ class NIC(SimComponent): # TODO: Inform the Node that a link has been connected self.connected_link = link + self.enable() _LOGGER.info(f"NIC {self} connected to Link {link}") def disconnect_link(self): @@ -266,8 +270,10 @@ class NIC(SimComponent): frame.decrement_ttl() frame.set_received_timestamp() self.pcap.capture(frame) - self.connected_node.receive_frame(frame=frame, from_nic=self) - return True + # If this destination or is broadcast + if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": + self.connected_node.receive_frame(frame=frame, from_nic=self) + return True return False def __str__(self) -> str: @@ -688,7 +694,6 @@ class ARPCache: frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) from_nic.send_frame(frame) - class ICMP: """ The ICMP (Internet Control Message Protocol) class. @@ -705,6 +710,8 @@ class ICMP: """ self.sys_log: SysLog = sys_log self.arp: ARPCache = arp_cache + self.request_replies = {} + def process_icmp(self, frame: Frame): """ @@ -733,6 +740,9 @@ class ICMP: src_nic.send_frame(frame) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") + if not self.request_replies.get(frame.icmp.identifier): + self.request_replies[frame.icmp.identifier] = 0 + self.request_replies[frame.icmp.identifier] += 1 def ping( self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None @@ -875,7 +885,7 @@ class Node(SimComponent): return state def show(self): - """Prints a table of the NICs on the Node..""" + """Prints a table of the NICs on the Node.""" from prettytable import PrettyTable table = PrettyTable(["MAC Address", "Address", "Default Gateway", "Speed", "Status"]) @@ -898,7 +908,8 @@ class Node(SimComponent): self.operating_state = NodeOperatingState.ON self.sys_log.info("Turned on") for nic in self.nics.values(): - nic.enable() + if nic.connected_link: + nic.enable() def power_off(self): """Power off the Node, disabling its NICs if it is in the ON state.""" @@ -961,7 +972,9 @@ class Node(SimComponent): sequence, identifier = 0, None while sequence < pings: sequence, identifier = self.icmp.ping(target_ip_address, sequence, identifier) - return True + passed = self.icmp.request_replies[identifier] == pings + self.icmp.request_replies.pop(identifier) + return passed self.sys_log.info("Ping failed as the node is turned off") return False diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py new file mode 100644 index 00000000..c5620b88 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -0,0 +1,86 @@ +from enum import Enum +from ipaddress import IPv4Address +from typing import Dict, List, Union + +from primaite.simulator.core import SimComponent +from primaite.simulator.network.hardware.base import Node, NIC +from prettytable import PrettyTable + +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +class ACLAction(Enum): + DENY = 0 + PERMIT = 1 + + +class ACLRule(SimComponent): + action: ACLAction + protocol: IPProtocol + src_ip: IPv4Address + src_wildcard: IPv4Address = IPv4Address("0.0.0.0") + src_port: Port + dst_ip: IPv4Address + dst_port: Port + + +class RouteTableEntry(SimComponent): + pass + + +class Router(Node): + num_ports: int + ethernet_ports: Dict[int, NIC] = {} + acl: List = [] + route_table: Dict = {} + + def __init__(self, hostname: str, num_ports: int = 5, **kwargs): + super().__init__(hostname=hostname, num_ports=num_ports, **kwargs) + + for i in range(1, self.num_ports + 1): + nic = NIC(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") + self.connect_nic(nic) + self.ethernet_ports[i] = nic + + def describe_state(self) -> Dict: + pass + + def configure_port( + self, + port: int, + ip_address: Union[IPv4Address, str], + subnet_mask: str + ): + if not isinstance(ip_address, IPv4Address): + ip_address = IPv4Address(ip_address) + nic = self.ethernet_ports[port] + nic.ip_address = ip_address + nic.subnet_mask = subnet_mask + self.sys_log.info(f"Configured port {port} with {ip_address=} {subnet_mask=}") + + def enable_port(self, port: int): + nic = self.ethernet_ports.get(port) + if nic: + nic.enable() + + def disable_port(self, port: int): + nic = self.ethernet_ports.get(port) + if nic: + nic.disable() + + def show(self): + """Prints a table of the NICs on the Node.""" + table = PrettyTable(["Port", "MAC Address", "Address", "Speed", "Status"]) + + for port, nic in self.ethernet_ports.items(): + table.add_row( + [ + port, + nic.mac_address, + f"{nic.ip_address}/{nic.ip_network.prefixlen}", + nic.speed, + "Enabled" if nic.enabled else "Disabled", + ] + ) + print(table) diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index 3840c302..d3d6541a 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -45,7 +45,7 @@ def test_multi_nic(): node_a.ping("192.168.0.11") - node_c.ping("10.0.0.12") + assert node_c.ping("10.0.0.12") def test_switched_network(): @@ -83,4 +83,4 @@ def test_switched_network(): link_nic_d_switch_2 = Link(endpoint_a=nic_d, endpoint_b=switch_2.switch_ports[2]) link_switch_1_switch_2 = Link(endpoint_a=switch_1.switch_ports[6], endpoint_b=switch_2.switch_ports[6]) - pc_a.ping("192.168.0.13") + assert pc_a.ping("192.168.0.13") diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py new file mode 100644 index 00000000..cca48c0d --- /dev/null +++ b/tests/integration_tests/network/test_routing.py @@ -0,0 +1,27 @@ +from primaite.simulator.network.hardware.base import Node, NIC, Link +from primaite.simulator.network.hardware.nodes.router import Router + + +def test_ping_fails_with_no_route(): + """Tests a larges network of Nodes and Switches with one node pinging another.""" + pc_a = Node(hostname="pc_a") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + pc_a.connect_nic(nic_a) + pc_a.power_on() + + pc_b = Node(hostname="pc_b") + nic_b = NIC(ip_address="192.168.1.10", subnet_mask="255.255.255.0", gateway="192.168.1.1") + pc_b.connect_nic(nic_b) + pc_b.power_on() + + router_1 = Router(hostname="router_1") + router_1.configure_port(1, "192.168.0.1", "255.255.255.0") + router_1.configure_port(2, "192.168.1.1", "255.255.255.0") + + router_1.power_on() + router_1.show() + + link_nic_a_router_1 = Link(endpoint_a=nic_a, endpoint_b=router_1.ethernet_ports[1]) + link_nic_b_router_1 = Link(endpoint_a=nic_b, endpoint_b=router_1.ethernet_ports[2]) + router_1.power_on() + #assert pc_a.ping("192.168.1.10") \ No newline at end of file From 1bf51c7741f1c45dd6846d9bb3ee611aadd02bf1 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 30 Aug 2023 21:38:55 +0100 Subject: [PATCH 11/21] #1800 - Added ACL and routing classes. - Added .show() methods to new router classes to enable inspection of the components as you would a real router. - Removed gateway from the NIC and added default_gateway to Node so that Node has a single default gateway. - Added some routing tests to check that ping can be performed when router between subnets. --- .../simulator/network/hardware/base.py | 146 +++-- .../network/hardware/nodes/router.py | 505 +++++++++++++++++- .../network/test_frame_transmission.py | 21 +- .../network/test_link_connection.py | 9 +- .../network/test_nic_link_connection.py | 3 +- .../integration_tests/network/test_routing.py | 56 +- .../_network/_hardware/nodes/__init__.py | 0 .../_network/_hardware/nodes/test_router.py | 104 ++++ .../_simulator/_network/_hardware/test_nic.py | 14 - 9 files changed, 739 insertions(+), 119 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index c64b9b67..921ebbcd 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -77,12 +77,10 @@ class NIC(SimComponent): ip_address: IPv4Address "The IP address assigned to the NIC for communication on an IP-based network." - subnet_mask: str + subnet_mask: IPv4Address "The subnet mask assigned to the NIC." - gateway: IPv4Address - "The default gateway IP address for forwarding network traffic to other networks. Randomly generated upon creation." mac_address: str - "The MAC address of the NIC. Defaults to a randomly set MAC address." + "The MAC address of the NIC. Defaults to a randomly set MAC address. Randomly generated upon creation." speed: int = 100 "The speed of the NIC in Mbps. Default is 100 Mbps." mtu: int = 1500 @@ -111,16 +109,10 @@ class NIC(SimComponent): """ if not isinstance(kwargs["ip_address"], IPv4Address): kwargs["ip_address"] = IPv4Address(kwargs["ip_address"]) - if not isinstance(kwargs["gateway"], IPv4Address): - kwargs["gateway"] = IPv4Address(kwargs["gateway"]) if "mac_address" not in kwargs: kwargs["mac_address"] = generate_mac_address() super().__init__(**kwargs) - if self.ip_address == self.gateway: - msg = f"NIC ip address {self.ip_address} cannot be the same as the gateway {self.gateway}" - _LOGGER.error(msg) - raise ValueError(msg) if self.ip_network.network_address == self.ip_address: msg = ( f"Failed to set IP address {self.ip_address} and subnet mask {self.subnet_mask} as it is a " @@ -274,6 +266,9 @@ class NIC(SimComponent): if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": self.connected_node.receive_frame(frame=frame, from_nic=self) return True + else: + self.connected_node.sys_log.info("Dropping frame not for me") + print(frame) return False def __str__(self) -> str: @@ -567,7 +562,21 @@ class ARPCache: self.arp: Dict[IPv4Address, ARPEntry] = {} self.nics: Dict[str, "NIC"] = {} - def _add_arp_cache_entry(self, ip_address: IPv4Address, mac_address: str, nic: NIC): + def show(self): + """Prints a table of ARC Cache.""" + table = PrettyTable(["IP Address", "MAC Address", "Via"]) + table.title = f"{self.sys_log.hostname} ARP Cache" + for ip, arp in self.arp.items(): + table.add_row( + [ + str(ip), + arp.mac_address, + self.nics[arp.nic_uuid].mac_address, + ] + ) + print(table) + + def add_arp_cache_entry(self, ip_address: IPv4Address, mac_address: str, nic: NIC, override: bool = False): """ Add an ARP entry to the cache. @@ -575,9 +584,14 @@ class ARPCache: :param mac_address: The MAC address associated with the IP address. :param nic: The NIC through which the NIC with the IP address is reachable. """ - self.sys_log.info(f"Adding ARP cache entry for {mac_address}/{ip_address} via NIC {nic}") - arp_entry = ARPEntry(mac_address=mac_address, nic_uuid=nic.uuid) - self.arp[ip_address] = arp_entry + for _nic in self.nics.values(): + if _nic.ip_address == ip_address: + return + if override or not self.arp.get(ip_address): + self.sys_log.info(f"Adding ARP cache entry for {mac_address}/{ip_address} via NIC {nic}") + arp_entry = ARPEntry(mac_address=mac_address, nic_uuid=nic.uuid) + + self.arp[ip_address] = arp_entry def _remove_arp_cache_entry(self, ip_address: IPv4Address): """ @@ -607,6 +621,7 @@ class ARPCache: :return: The NIC associated with the IP address, or None if not found. """ arp_entry = self.arp.get(ip_address) + if arp_entry: return self.nics[arp_entry.nic_uuid] @@ -641,6 +656,29 @@ class ARPCache: frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) nic.send_frame(frame) + def send_arp_reply(self, arp_reply: ARPPacket, from_nic: NIC): + """ + Send an ARP reply back through the NIC it came from. + + :param arp_reply: The ARP reply to send. + :param from_nic: The NIC to send the ARP reply from. + """ + self.sys_log.info( + f"Sending ARP reply from {arp_reply.sender_mac_addr}/{arp_reply.sender_ip} " + f"to {arp_reply.target_ip}/{arp_reply.target_mac_addr} " + ) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + ip_packet = IPPacket( + src_ip=arp_reply.sender_ip, + dst_ip=arp_reply.target_ip, + ) + + ethernet_header = EthernetHeader(src_mac_addr=arp_reply.sender_mac_addr, dst_mac_addr=arp_reply.target_mac_addr) + + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_reply) + from_nic.send_frame(frame) + def process_arp_packet(self, from_nic: NIC, arp_packet: ARPPacket): """ Process a received ARP packet, handling both ARP requests and responses. @@ -656,7 +694,7 @@ class ARPCache: self.sys_log.info( f"Received ARP response for {arp_packet.sender_ip} from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) - self._add_arp_cache_entry( + self.add_arp_cache_entry( ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic ) return @@ -673,26 +711,13 @@ class ARPCache: return # Matched ARP request - self._add_arp_cache_entry(ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + self.add_arp_cache_entry(ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic) arp_packet = arp_packet.generate_reply(from_nic.mac_address) - self.sys_log.info( - f"Sending ARP reply from {arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " - f"to {arp_packet.target_ip}/{arp_packet.target_mac_addr} " - ) + self.send_arp_reply(arp_packet, from_nic) - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + def __contains__(self, item) -> bool: + return item in self.arp - # Network Layer - ip_packet = IPPacket( - src_ip=arp_packet.sender_ip, - dst_ip=arp_packet.target_ip, - ) - # Data Link Layer - ethernet_header = EthernetHeader( - src_mac_addr=arp_packet.sender_mac_addr, dst_mac_addr=arp_packet.target_mac_addr - ) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) - from_nic.send_frame(frame) class ICMP: """ @@ -712,8 +737,7 @@ class ICMP: self.arp: ARPCache = arp_cache self.request_replies = {} - - def process_icmp(self, frame: Frame): + def process_icmp(self, frame: Frame, from_nic: NIC, is_reattempt: bool = False): """ Process an ICMP packet, including handling echo requests and replies. @@ -722,7 +746,15 @@ class ICMP: if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) + src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) + if not src_nic: + print(self.sys_log.hostname) + print(frame.ip.src_ip) + self.arp.show() + self.arp.send_arp_request(frame.ip.src_ip) + self.process_icmp(frame=frame, from_nic=from_nic, is_reattempt=True) + return tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer @@ -737,6 +769,7 @@ class ICMP: ) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") + src_nic.send_frame(frame) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") @@ -745,7 +778,7 @@ class ICMP: self.request_replies[frame.icmp.identifier] += 1 def ping( - self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None + self, target_ip_address: IPv4Address, sequence: int = 0, identifier: Optional[int] = None, pings: int = 4 ) -> Tuple[int, Union[int, None]]: """ Send an ICMP echo request (ping) to a target IP address and manage the sequence and identifier. @@ -757,13 +790,21 @@ class ICMP: was not found in the ARP cache. """ nic = self.arp.get_arp_cache_nic(target_ip_address) - # TODO: Eventually this ARP request needs to be done elsewhere. It's not the resonsibility of the + # TODO: Eventually this ARP request needs to be done elsewhere. It's not the responsibility of the # ping function to handle ARP lookups + + # Already tried once and cannot get ARP entry, stop trying + if sequence == -1: + if not nic: + return 4, None + else: + sequence = 0 + # No existing ARP entry if not nic: self.sys_log.info(f"No entry in ARP cache for {target_ip_address}") self.arp.send_arp_request(target_ip_address) - return 0, None + return -1, None # ARP entry exists sequence += 1 @@ -812,6 +853,8 @@ class Node(SimComponent): hostname: str "The node hostname on the network." + default_gateway: Optional[IPv4Address] = None + "The default gateway IP address for forwarding network traffic to other networks." operating_state: NodeOperatingState = NodeOperatingState.OFF "The hardware state of the node." nics: Dict[str, NIC] = {} @@ -843,9 +886,12 @@ class Node(SimComponent): This method initializes the ARP cache, ICMP handler, session manager, and software manager if they are not provided. """ + if kwargs.get("default_gateway"): + if not isinstance(kwargs["default_gateway"], IPv4Address): + kwargs["default_gateway"] = IPv4Address(kwargs["default_gateway"]) if not kwargs.get("sys_log"): kwargs["sys_log"] = SysLog(kwargs["hostname"]) - if not kwargs.get("arp_cache"): + if not kwargs.get("arp"): kwargs["arp"] = ARPCache(sys_log=kwargs.get("sys_log")) if not kwargs.get("icmp"): kwargs["icmp"] = ICMP(sys_log=kwargs.get("sys_log"), arp_cache=kwargs.get("arp")) @@ -886,10 +932,8 @@ class Node(SimComponent): def show(self): """Prints a table of the NICs on the Node.""" - from prettytable import PrettyTable - table = PrettyTable(["MAC Address", "Address", "Default Gateway", "Speed", "Status"]) - + table.title = f"{self.hostname} Network Interface Cards" for nic in self.nics.values(): table.add_row( [ @@ -967,13 +1011,18 @@ class Node(SimComponent): """ if not isinstance(target_ip_address, IPv4Address): target_ip_address = IPv4Address(target_ip_address) + if target_ip_address.is_loopback: + self.sys_log.info("Pinging loopback address") + return any(nic.enabled for nic in self.nics.values()) if self.operating_state == NodeOperatingState.ON: self.sys_log.info(f"Attempting to ping {target_ip_address}") sequence, identifier = 0, None while sequence < pings: - sequence, identifier = self.icmp.ping(target_ip_address, sequence, identifier) - passed = self.icmp.request_replies[identifier] == pings - self.icmp.request_replies.pop(identifier) + sequence, identifier = self.icmp.ping(target_ip_address, sequence, identifier, pings) + request_replies = self.icmp.request_replies.get(identifier) + passed = request_replies == pings + if request_replies: + self.icmp.request_replies.pop(identifier) return passed self.sys_log.info("Ping failed as the node is turned off") return False @@ -997,13 +1046,18 @@ class Node(SimComponent): :param frame: The Frame being received. :param from_nic: The NIC that received the frame. """ + if frame.ip: + if frame.ip.src_ip in self.arp: + self.arp.add_arp_cache_entry( + ip_address=frame.ip.src_ip, mac_address=frame.ethernet.src_mac_addr, nic=from_nic + ) if frame.ip.protocol == IPProtocol.TCP: if frame.tcp.src_port == Port.ARP: self.arp.process_arp_packet(from_nic=from_nic, arp_packet=frame.arp) elif frame.ip.protocol == IPProtocol.UDP: pass elif frame.ip.protocol == IPProtocol.ICMP: - self.icmp.process_icmp(frame=frame) + self.icmp.process_icmp(frame=frame, from_nic=from_nic) class Switch(Node): @@ -1027,7 +1081,7 @@ class Switch(Node): def show(self): """Prints a table of the SwitchPorts on the Switch.""" table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) - + table.title = f"{self.hostname} Switch Ports" for port_num, port in self.switch_ports.items(): table.add_row([port_num, port.mac_address, port.speed, "Enabled" if port.enabled else "Disabled"]) print(table) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index c5620b88..528e4a73 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -1,63 +1,514 @@ -from enum import Enum -from ipaddress import IPv4Address -from typing import Dict, List, Union +from __future__ import annotations + +from enum import Enum +from ipaddress import IPv4Address, IPv4Network +from typing import Dict, List, Optional, Tuple, Union -from primaite.simulator.core import SimComponent -from primaite.simulator.network.hardware.base import Node, NIC from prettytable import PrettyTable -from primaite.simulator.network.transmission.network_layer import IPProtocol -from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.core import SimComponent +from primaite.simulator.network.hardware.base import ARPCache, ICMP, NIC, Node +from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame +from primaite.simulator.network.transmission.network_layer import ICMPPacket, ICMPType, IPPacket, IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader +from primaite.simulator.system.core.sys_log import SysLog class ACLAction(Enum): + """Enum for defining the ACL action types.""" + DENY = 0 PERMIT = 1 class ACLRule(SimComponent): - action: ACLAction - protocol: IPProtocol - src_ip: IPv4Address - src_wildcard: IPv4Address = IPv4Address("0.0.0.0") - src_port: Port - dst_ip: IPv4Address - dst_port: Port + def describe_state(self) -> Dict: + pass + + action: ACLAction = ACLAction.DENY + protocol: Optional[IPProtocol] = None + src_ip: Optional[IPv4Address] = None + src_port: Optional[Port] = None + dst_ip: Optional[IPv4Address] = None + dst_port: Optional[Port] = None + + def __str__(self) -> str: + rule_strings = [] + for key, value in self.model_dump(exclude={"uuid", "action_manager"}).items(): + if value is None: + value = "ANY" + if isinstance(value, Enum): + rule_strings.append(f"{key}={value.name}") + else: + rule_strings.append(f"{key}={value}") + return ", ".join(rule_strings) -class RouteTableEntry(SimComponent): - pass +class AccessControlList(SimComponent): + sys_log: SysLog + implicit_action: ACLAction + implicit_rule: ACLRule + max_acl_rules: int = 25 + _acl: List[Optional[ACLRule]] = [None] * 24 + + def __init__(self, **kwargs) -> None: + if not kwargs.get("implicit_action"): + kwargs["implicit_action"] = ACLAction.DENY + if not kwargs.get("max_acl_rules"): + kwargs["max_acl_rules"] = 25 + kwargs["implicit_rule"] = ACLRule(action=kwargs["implicit_action"]) + kwargs["_acl"] = [None] * (kwargs["max_acl_rules"] - 1) + + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + pass + + @property + def acl(self) -> List[Optional[ACLRule]]: + return self._acl + + def add_rule( + self, + action: ACLAction, + protocol: Optional[IPProtocol] = None, + src_ip: Optional[Union[str, IPv4Address]] = None, + src_port: Optional[Port] = None, + dst_ip: Optional[Union[str, IPv4Address]] = None, + dst_port: Optional[Port] = None, + position: int = 0, + ) -> None: + if isinstance(src_ip, str): + src_ip = IPv4Address(src_ip) + if isinstance(dst_ip, str): + dst_ip = IPv4Address(dst_ip) + if 0 <= position < self.max_acl_rules: + self._acl[position] = ACLRule( + action=action, src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, src_port=src_port, dst_port=dst_port + ) + else: + raise ValueError(f"Position {position} is out of bounds.") + + def remove_rule(self, position: int) -> None: + if 0 <= position < self.max_acl_rules: + self._acl[position] = None + else: + raise ValueError(f"Position {position} is out of bounds.") + + def is_permitted( + self, + protocol: IPProtocol, + src_ip: Union[str, IPv4Address], + src_port: Optional[Port], + dst_ip: Union[str, IPv4Address], + dst_port: Optional[Port], + ) -> Tuple[bool, Optional[Union[str, ACLRule]]]: + if not isinstance(src_ip, IPv4Address): + src_ip = IPv4Address(src_ip) + if not isinstance(dst_ip, IPv4Address): + dst_ip = IPv4Address(dst_ip) + for rule in self._acl: + if not rule: + continue + + if ( + (rule.src_ip == src_ip or rule.src_ip is None) + and (rule.dst_ip == dst_ip or rule.dst_ip is None) + and (rule.protocol == protocol or rule.protocol is None) + and (rule.src_port == src_port or rule.src_port is None) + and (rule.dst_port == dst_port or rule.dst_port is None) + ): + return rule.action == ACLAction.PERMIT, rule + + return self.implicit_action == ACLAction.PERMIT, f"Implicit {self.implicit_action.name}" + + def get_relevant_rules( + self, + protocol: IPProtocol, + src_ip: Union[str, IPv4Address], + src_port: Port, + dst_ip: Union[str, IPv4Address], + dst_port: Port, + ) -> List[ACLRule]: + if not isinstance(src_ip, IPv4Address): + src_ip = IPv4Address(src_ip) + if not isinstance(dst_ip, IPv4Address): + dst_ip = IPv4Address(dst_ip) + relevant_rules = [] + for rule in self._acl: + if rule is None: + continue + + if ( + (rule.src_ip == src_ip or rule.src_ip is None) + or (rule.dst_ip == dst_ip or rule.dst_ip is None) + or (rule.protocol == protocol or rule.protocol is None) + or (rule.src_port == src_port or rule.src_port is None) + or (rule.dst_port == dst_port or rule.dst_port is None) + ): + relevant_rules.append(rule) + + return relevant_rules + + def show(self): + """Prints a table of the routes in the RouteTable.""" + """ + action: ACLAction + protocol: Optional[IPProtocol] + src_ip: Optional[IPv4Address] + src_port: Optional[Port] + dst_ip: Optional[IPv4Address] + dst_port: Optional[Port] + """ + table = PrettyTable(["Index", "Action", "Protocol", "Src IP", "Src Port", "Dst IP", "Dst Port"]) + table.title = f"{self.sys_log.hostname} Access Control List" + for index, rule in enumerate(self.acl + [self.implicit_rule]): + if rule: + table.add_row( + [ + index, + rule.action.name if rule.action else "ANY", + rule.protocol.name if rule.protocol else "ANY", + rule.src_ip if rule.src_ip else "ANY", + f"{rule.src_port.value} ({rule.src_port.name})" if rule.src_port else "ANY", + rule.dst_ip if rule.dst_ip else "ANY", + f"{rule.dst_port.value} ({rule.dst_port.name})" if rule.dst_port else "ANY", + ] + ) + print(table) + + +class RouteEntry(SimComponent): + """ + Represents a single entry in a routing table. + + Attributes: + address (IPv4Address): The destination IP address or network address. + subnet_mask (IPv4Address): The subnet mask for the network. + next_hop (IPv4Address): The next hop IP address to which packets should be forwarded. + metric (int): The cost metric for this route. Default is 0.0. + + Example: + >>> entry = RouteEntry( + ... IPv4Address("192.168.1.0"), + ... IPv4Address("255.255.255.0"), + ... IPv4Address("192.168.2.1"), + ... metric=5 + ... ) + """ + + address: IPv4Address + "The destination IP address or network address." + subnet_mask: IPv4Address + "The subnet mask for the network." + next_hop: IPv4Address + "The next hop IP address to which packets should be forwarded." + metric: float = 0.0 + "The cost metric for this route. Default is 0.0." + + def __init__(self, **kwargs): + for key in {"address", "subnet_mask", "next_hop"}: + if not isinstance(kwargs[key], IPv4Address): + kwargs[key] = IPv4Address(kwargs[key]) + super().__init__(**kwargs) + + def describe_state(self) -> Dict: + pass + + +class RouteTable(SimComponent): + """ + Represents a routing table holding multiple route entries. + + Attributes: + routes (List[RouteEntry]): A list of RouteEntry objects. + + Methods: + add_route: Add a route to the routing table. + find_best_route: Find the best route for a given destination IP. + + Example: + >>> rt = RouteTable() + >>> rt.add_route( + ... RouteEntry( + ... IPv4Address("192.168.1.0"), + ... IPv4Address("255.255.255.0"), + ... IPv4Address("192.168.2.1"), + ... metric=5 + ... ) + ... ) + >>> best_route = rt.find_best_route(IPv4Address("192.168.1.34")) + """ + + routes: List[RouteEntry] = [] + sys_log: SysLog + + def describe_state(self) -> Dict: + pass + + def add_route( + self, + address: Union[IPv4Address, str], + subnet_mask: Union[IPv4Address, str], + next_hop: Union[IPv4Address, str], + metric: float = 0.0, + ): + """Add a route to the routing table. + + :param route: A RouteEntry object representing the route. + """ + for key in {address, subnet_mask, next_hop}: + if not isinstance(key, IPv4Address): + key = IPv4Address(key) + route = RouteEntry(address=address, subnet_mask=subnet_mask, next_hop=next_hop, metric=metric) + self.routes.append(route) + + def find_best_route(self, destination_ip: Union[str, IPv4Address]) -> Optional[RouteEntry]: + """ + Find the best route for a given destination IP. + + :param destination_ip: The destination IPv4Address to find the route for. + :return: The best matching RouteEntry, or None if no route matches. + + The algorithm uses Longest Prefix Match and considers metrics to find the best route. + """ + if not isinstance(destination_ip, IPv4Address): + destination_ip = IPv4Address(destination_ip) + best_route = None + longest_prefix = -1 + lowest_metric = float("inf") # Initialise at infinity as any other number we compare to it will be smaller + + for route in self.routes: + route_network = IPv4Network(f"{route.address}/{route.subnet_mask}", strict=False) + prefix_len = route_network.prefixlen + + if destination_ip in route_network: + if prefix_len > longest_prefix or (prefix_len == longest_prefix and route.metric < lowest_metric): + best_route = route + longest_prefix = prefix_len + lowest_metric = route.metric + + return best_route + + def show(self): + """Prints a table of the routes in the RouteTable.""" + table = PrettyTable(["Index", "Address", "Next Hop", "Metric"]) + table.title = f"{self.sys_log.hostname} Route Table" + for index, route in enumerate(self.routes): + network = IPv4Network(f"{route.address}/{route.subnet_mask}") + table.add_row([index, f"{route.address}/{network.prefixlen}", route.next_hop, route.metric]) + print(table) + + +class RouterARPCache(ARPCache): + def __init__(self, sys_log: SysLog, router: Router): + super().__init__(sys_log) + self.router: Router = router + + def process_arp_packet(self, from_nic: NIC, frame: Frame): + """ + Overridden method to process a received ARP packet in a router-specific way. + + :param from_nic: The NIC that received the ARP packet. + :param frame: The original arp frame. + """ + arp_packet = frame.arp + + # ARP Reply + if not arp_packet.request: + for nic in self.router.nics.values(): + if arp_packet.target_ip == nic.ip_address: + # reply to the Router specifically + self.sys_log.info( + f"Received ARP response for {arp_packet.sender_ip} from {arp_packet.sender_mac_addr} via NIC {from_nic}" + ) + self.add_arp_cache_entry( + ip_address=arp_packet.sender_ip, + mac_address=arp_packet.sender_mac_addr, + nic=from_nic, + ) + return + + # Reply for a connected requested + nic = self.get_arp_cache_nic(arp_packet.target_ip) + if nic: + self.sys_log.info(f"Forwarding arp reply for {arp_packet.target_ip}, from {arp_packet.sender_ip}") + arp_packet.sender_mac_addr = nic.mac_address + frame.decrement_ttl() + nic.send_frame(frame) + + # ARP Request + self.sys_log.info( + f"Received ARP request for {arp_packet.target_ip} from " + f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + ) + # Matched ARP request + self.add_arp_cache_entry(ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + arp_packet = arp_packet.generate_reply(from_nic.mac_address) + self.send_arp_reply(arp_packet, from_nic) + + # If the target IP matches one of the router's NICs + for nic in self.nics.values(): + if nic.enabled and nic.ip_address == arp_packet.target_ip: + arp_reply = arp_packet.generate_reply(from_nic.mac_address) + self.send_arp_reply(arp_reply, from_nic) + return + + +class RouterICMP(ICMP): + router: Router + + def __init__(self, sys_log: SysLog, arp_cache: ARPCache, router: Router): + super().__init__(sys_log, arp_cache) + self.router = router + + def process_icmp(self, frame: Frame, from_nic: NIC, is_reattempt: bool = False): + if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: + # determine if request is for router interface or whether it needs to be routed + + for nic in self.router.nics.values(): + if nic.ip_address == frame.ip.dst_ip and nic.enabled: + # reply to the request + self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") + target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) + src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + + # Network Layer + ip_packet = IPPacket(src_ip=nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) + # Data Link Layer + ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) + icmp_reply_packet = ICMPPacket( + icmp_type=ICMPType.ECHO_REPLY, + icmp_code=0, + identifier=frame.icmp.identifier, + sequence=frame.icmp.sequence + 1, + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") + + src_nic.send_frame(frame) + return + + # Route the frame + self.router.route_frame(frame, from_nic) + elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: + self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") + if not self.request_replies.get(frame.icmp.identifier): + self.request_replies[frame.icmp.identifier] = 0 + self.request_replies[frame.icmp.identifier] += 1 class Router(Node): num_ports: int ethernet_ports: Dict[int, NIC] = {} - acl: List = [] - route_table: Dict = {} + acl: AccessControlList + route_table: RouteTable + arp: RouterARPCache + icmp: RouterICMP def __init__(self, hostname: str, num_ports: int = 5, **kwargs): + if not kwargs.get("sys_log"): + kwargs["sys_log"] = SysLog(hostname) + if not kwargs.get("acl"): + kwargs["acl"] = AccessControlList(sys_log=kwargs["sys_log"], implicit_action=ACLAction.DENY) + if not kwargs.get("route_table"): + kwargs["route_table"] = RouteTable(sys_log=kwargs["sys_log"]) + if not kwargs.get("arp"): + kwargs["arp"] = RouterARPCache(sys_log=kwargs.get("sys_log"), router=self) + if not kwargs.get("icmp"): + kwargs["icmp"] = RouterICMP(sys_log=kwargs.get("sys_log"), arp_cache=kwargs.get("arp"), router=self) super().__init__(hostname=hostname, num_ports=num_ports, **kwargs) - for i in range(1, self.num_ports + 1): nic = NIC(ip_address="127.0.0.1", subnet_mask="255.0.0.0", gateway="0.0.0.0") self.connect_nic(nic) self.ethernet_ports[i] = nic + self.arp.nics = self.nics + self.icmp.arp = self.arp + + def _get_port_of_nic(self, target_nic: NIC) -> Optional[int]: + for port, nic in self.ethernet_ports.items(): + if nic == target_nic: + return port + def describe_state(self) -> Dict: pass - def configure_port( - self, - port: int, - ip_address: Union[IPv4Address, str], - subnet_mask: str - ): + def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: + if not re_attempt: + # Check if src ip is on network of one of the NICs + nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip) + target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip) + if not nic: + self.arp.send_arp_request(frame.ip.dst_ip) + return self.route_frame(frame=frame, from_nic=from_nic, re_attempt=True) + for nic in self.nics.values(): + if nic.enabled and frame.ip.dst_ip in nic.ip_network: + from_port = self._get_port_of_nic(from_nic) + to_port = self._get_port_of_nic(nic) + self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + frame.decrement_ttl() + frame.ethernet.src_mac_addr = nic.mac_address + frame.ethernet.dst_mac_addr = target_mac + nic.send_frame(frame) + return + else: + self.sys_log.info(f"Destination {frame.ip.dst_ip} is unreachable") + + def receive_frame(self, frame: Frame, from_nic: NIC): + """ + Receive a Frame from the connected NIC and process it. + + Depending on the protocol, the frame is passed to the appropriate handler such as ARP or ICMP, or up to the + SessionManager if no code manager exists. + + :param frame: The Frame being received. + :param from_nic: The NIC that received the frame. + """ + route_frame = False + protocol = frame.ip.protocol + src_ip = frame.ip.src_ip + dst_ip = frame.ip.dst_ip + src_port = None + dst_port = None + if frame.ip.protocol == IPProtocol.TCP: + src_port = frame.tcp.src_port + dst_port = frame.tcp.dst_port + elif frame.ip.protocol == IPProtocol.UDP: + src_port = frame.udp.src_port + dst_port = frame.udp.dst_port + + # Check if it's permitted + permitted, rule = self.acl.is_permitted( + protocol=protocol, src_ip=src_ip, src_port=src_port, dst_ip=dst_ip, dst_port=dst_port + ) + if not permitted: + at_port = self._get_port_of_nic(from_nic) + self.sys_log.info(f"Frame blocked at port {at_port} by rule {rule}") + return + if not self.arp.get_arp_cache_nic(src_ip): + self.arp.add_arp_cache_entry(src_ip, frame.ethernet.src_mac_addr, from_nic) + if frame.ip.protocol == IPProtocol.ICMP: + self.icmp.process_icmp(frame=frame, from_nic=from_nic) + else: + if src_port == Port.ARP: + self.arp.process_arp_packet(from_nic=from_nic, frame=frame) + else: + # All other traffic + route_frame = True + if route_frame: + self.route_frame(frame, from_nic) + + def configure_port(self, port: int, ip_address: Union[IPv4Address, str], subnet_mask: Union[IPv4Address, str]): if not isinstance(ip_address, IPv4Address): ip_address = IPv4Address(ip_address) + if not isinstance(subnet_mask, IPv4Address): + subnet_mask = IPv4Address(subnet_mask) nic = self.ethernet_ports[port] nic.ip_address = ip_address nic.subnet_mask = subnet_mask - self.sys_log.info(f"Configured port {port} with {ip_address=} {subnet_mask=}") + self.sys_log.info(f"Configured port {port} with ip_address={ip_address}/{nic.ip_network.prefixlen}") def enable_port(self, port: int): nic = self.ethernet_ports.get(port) @@ -72,7 +523,7 @@ class Router(Node): def show(self): """Prints a table of the NICs on the Node.""" table = PrettyTable(["Port", "MAC Address", "Address", "Speed", "Status"]) - + table.title = f"{self.hostname} Ethernet Interfaces" for port, nic in self.ethernet_ports.items(): table.add_row( [ diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index d3d6541a..34b76060 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -3,14 +3,13 @@ from primaite.simulator.network.hardware.base import Link, NIC, Node, Switch def test_node_to_node_ping(): """Tests two Nodes are able to ping each other.""" - # TODO Add actual checks. Manual check performed for now. node_a = Node(hostname="node_a") - nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") node_a.connect_nic(nic_a) node_a.power_on() node_b = Node(hostname="node_b") - nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") node_b.connect_nic(nic_b) node_b.power_on() @@ -23,19 +22,19 @@ def test_multi_nic(): """Tests that Nodes with multiple NICs can ping each other and the data go across the correct links.""" # TODO Add actual checks. Manual check performed for now. node_a = Node(hostname="node_a") - nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") node_a.connect_nic(nic_a) node_a.power_on() node_b = Node(hostname="node_b") - nic_b1 = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") - nic_b2 = NIC(ip_address="10.0.0.12", subnet_mask="255.0.0.0", gateway="10.0.0.1") + nic_b1 = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") + nic_b2 = NIC(ip_address="10.0.0.12", subnet_mask="255.0.0.0") node_b.connect_nic(nic_b1) node_b.connect_nic(nic_b2) node_b.power_on() node_c = Node(hostname="node_c") - nic_c = NIC(ip_address="10.0.0.13", subnet_mask="255.0.0.0", gateway="10.0.0.1") + nic_c = NIC(ip_address="10.0.0.13", subnet_mask="255.0.0.0") node_c.connect_nic(nic_c) node_c.power_on() @@ -52,22 +51,22 @@ def test_switched_network(): """Tests a larges network of Nodes and Switches with one node pinging another.""" # TODO Add actual checks. Manual check performed for now. pc_a = Node(hostname="pc_a") - nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") pc_a.connect_nic(nic_a) pc_a.power_on() pc_b = Node(hostname="pc_b") - nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") pc_b.connect_nic(nic_b) pc_b.power_on() pc_c = Node(hostname="pc_c") - nic_c = NIC(ip_address="192.168.0.12", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_c = NIC(ip_address="192.168.0.12", subnet_mask="255.255.255.0") pc_c.connect_nic(nic_c) pc_c.power_on() pc_d = Node(hostname="pc_d") - nic_d = NIC(ip_address="192.168.0.13", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_d = NIC(ip_address="192.168.0.13", subnet_mask="255.255.255.0") pc_d.connect_nic(nic_d) pc_d.power_on() diff --git a/tests/integration_tests/network/test_link_connection.py b/tests/integration_tests/network/test_link_connection.py index e08e40b9..ef65f078 100644 --- a/tests/integration_tests/network/test_link_connection.py +++ b/tests/integration_tests/network/test_link_connection.py @@ -4,18 +4,17 @@ from primaite.simulator.network.hardware.base import Link, NIC, Node def test_link_up(): """Tests Nodes, NICs, and Links can all be connected and be in an enabled/up state.""" node_a = Node(hostname="node_a") - nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") node_a.connect_nic(nic_a) node_a.power_on() - assert nic_a.enabled node_b = Node(hostname="node_b") - nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0", gateway="192.168.0.1") + nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") node_b.connect_nic(nic_b) node_b.power_on() - assert nic_b.enabled - link = Link(endpoint_a=nic_a, endpoint_b=nic_b) + assert nic_a.enabled + assert nic_b.enabled assert link.is_up diff --git a/tests/integration_tests/network/test_nic_link_connection.py b/tests/integration_tests/network/test_nic_link_connection.py index 52a0c735..f051d026 100644 --- a/tests/integration_tests/network/test_nic_link_connection.py +++ b/tests/integration_tests/network/test_nic_link_connection.py @@ -8,7 +8,6 @@ def test_link_fails_with_same_nic(): with pytest.raises(ValueError): nic_a = NIC( ip_address="192.168.1.2", - subnet_mask="255.255.255.0", - gateway="192.168.0.1", + subnet_mask="255.255.255.0" ) Link(endpoint_a=nic_a, endpoint_b=nic_a) diff --git a/tests/integration_tests/network/test_routing.py b/tests/integration_tests/network/test_routing.py index cca48c0d..cb420e22 100644 --- a/tests/integration_tests/network/test_routing.py +++ b/tests/integration_tests/network/test_routing.py @@ -1,27 +1,55 @@ -from primaite.simulator.network.hardware.base import Node, NIC, Link -from primaite.simulator.network.hardware.nodes.router import Router +from typing import Tuple + +import pytest + +from primaite.simulator.network.hardware.base import Link, NIC, Node +from primaite.simulator.network.hardware.nodes.router import ACLAction, Router +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port -def test_ping_fails_with_no_route(): - """Tests a larges network of Nodes and Switches with one node pinging another.""" - pc_a = Node(hostname="pc_a") - nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0", gateway="192.168.0.1") +@pytest.fixture(scope="function") +def pc_a_pc_b_router_1() -> Tuple[Node, Node, Router]: + pc_a = Node(hostname="pc_a", default_gateway="192.168.0.1") + nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") pc_a.connect_nic(nic_a) pc_a.power_on() - pc_b = Node(hostname="pc_b") - nic_b = NIC(ip_address="192.168.1.10", subnet_mask="255.255.255.0", gateway="192.168.1.1") + pc_b = Node(hostname="pc_b", default_gateway="192.168.1.1") + nic_b = NIC(ip_address="192.168.1.10", subnet_mask="255.255.255.0") pc_b.connect_nic(nic_b) pc_b.power_on() router_1 = Router(hostname="router_1") + router_1.power_on() + router_1.configure_port(1, "192.168.0.1", "255.255.255.0") router_1.configure_port(2, "192.168.1.1", "255.255.255.0") - router_1.power_on() - router_1.show() + Link(endpoint_a=nic_a, endpoint_b=router_1.ethernet_ports[1]) + Link(endpoint_a=nic_b, endpoint_b=router_1.ethernet_ports[2]) + router_1.enable_port(1) + router_1.enable_port(2) - link_nic_a_router_1 = Link(endpoint_a=nic_a, endpoint_b=router_1.ethernet_ports[1]) - link_nic_b_router_1 = Link(endpoint_a=nic_b, endpoint_b=router_1.ethernet_ports[2]) - router_1.power_on() - #assert pc_a.ping("192.168.1.10") \ No newline at end of file + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) + + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) + return pc_a, pc_b, router_1 + + +def test_ping_default_gateway(pc_a_pc_b_router_1): + pc_a, pc_b, router_1 = pc_a_pc_b_router_1 + + assert pc_a.ping(pc_a.default_gateway) + + +def test_ping_other_router_port(pc_a_pc_b_router_1): + pc_a, pc_b, router_1 = pc_a_pc_b_router_1 + + assert pc_a.ping(pc_b.default_gateway) + + +def test_host_on_other_subnet(pc_a_pc_b_router_1): + pc_a, pc_b, router_1 = pc_a_pc_b_router_1 + + assert pc_a.ping("192.168.1.10") diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/__init__.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py new file mode 100644 index 00000000..48d0fc06 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py @@ -0,0 +1,104 @@ +from ipaddress import IPv4Address + +from primaite.simulator.network.hardware.nodes.router import AccessControlList, ACLAction, ACLRule +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def test_add_rule(): + acl = AccessControlList() + acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.1"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.2"), + dst_port=Port(80), + position=1, + ) + assert acl.acl[1].action == ACLAction.PERMIT + assert acl.acl[1].protocol == IPProtocol.TCP + assert acl.acl[1].src_ip == IPv4Address("192.168.1.1") + assert acl.acl[1].src_port == Port(8080) + assert acl.acl[1].dst_ip == IPv4Address("192.168.1.2") + assert acl.acl[1].dst_port == Port(80) + + +def test_remove_rule(): + acl = AccessControlList() + acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.1"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.2"), + dst_port=Port(80), + position=1, + ) + acl.remove_rule(1) + assert not acl.acl[1] + + +def test_rules(): + acl = AccessControlList() + acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.1"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.2"), + dst_port=Port(80), + position=1, + ) + acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.3"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.4"), + dst_port=Port(80), + position=2, + ) + assert acl.is_permitted( + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.1"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.2"), + dst_port=Port(80), + ) + assert not acl.is_permitted( + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.3"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.4"), + dst_port=Port(80), + ) + + +def test_default_rule(): + acl = AccessControlList() + acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.1"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.2"), + dst_port=Port(80), + position=1, + ) + acl.add_rule( + action=ACLAction.DENY, + protocol=IPProtocol.TCP, + src_ip=IPv4Address("192.168.1.3"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.4"), + dst_port=Port(80), + position=2, + ) + assert not acl.is_permitted( + protocol=IPProtocol.UDP, + src_ip=IPv4Address("192.168.1.5"), + src_port=Port(8080), + dst_ip=IPv4Address("192.168.1.12"), + dst_port=Port(80), + ) diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py index dc508508..11873128 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py @@ -32,10 +32,8 @@ def test_nic_ip_address_type_conversion(): nic = NIC( ip_address="192.168.1.2", subnet_mask="255.255.255.0", - gateway="192.168.0.1", ) assert isinstance(nic.ip_address, IPv4Address) - assert isinstance(nic.gateway, IPv4Address) def test_nic_deserialize(): @@ -43,7 +41,6 @@ def test_nic_deserialize(): nic = NIC( ip_address="192.168.1.2", subnet_mask="255.255.255.0", - gateway="192.168.0.1", ) nic_json = nic.model_dump_json() @@ -51,21 +48,10 @@ def test_nic_deserialize(): assert nic == deserialized_nic -def test_nic_ip_address_as_gateway_fails(): - """Tests NIC creation fails if ip address is the same as the gateway.""" - with pytest.raises(ValueError): - NIC( - ip_address="192.168.0.1", - subnet_mask="255.255.255.0", - gateway="192.168.0.1", - ) - - def test_nic_ip_address_as_network_address_fails(): """Tests NIC creation fails if ip address and subnet mask are a network address.""" with pytest.raises(ValueError): NIC( ip_address="192.168.0.0", subnet_mask="255.255.255.0", - gateway="192.168.0.1", ) From e73d7f49d68e5e6b0e481db098d1e0aa49c044fd Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 31 Aug 2023 11:03:38 +0100 Subject: [PATCH 12/21] #1800 - Fixed routing and processing of ICMP packets in the Router class --- .../simulator/network/hardware/base.py | 9 +- .../network/hardware/nodes/router.py | 171 ++++++++++-------- 2 files changed, 98 insertions(+), 82 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 921ebbcd..4803150d 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -744,14 +744,12 @@ class ICMP: :param frame: The Frame containing the ICMP packet to process. """ if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: - self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") + if not is_reattempt: + self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) if not src_nic: - print(self.sys_log.hostname) - print(frame.ip.src_ip) - self.arp.show() self.arp.send_arp_request(frame.ip.src_ip) self.process_icmp(frame=frame, from_nic=from_nic, is_reattempt=True) return @@ -932,14 +930,13 @@ class Node(SimComponent): def show(self): """Prints a table of the NICs on the Node.""" - table = PrettyTable(["MAC Address", "Address", "Default Gateway", "Speed", "Status"]) + table = PrettyTable(["MAC Address", "Address", "Speed", "Status"]) table.title = f"{self.hostname} Network Interface Cards" for nic in self.nics.values(): table.add_row( [ nic.mac_address, f"{nic.ip_address}/{nic.ip_network.prefixlen}", - nic.gateway, nic.speed, "Enabled" if nic.enabled else "Disabled", ] diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 528e4a73..7db92938 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -69,14 +69,14 @@ class AccessControlList(SimComponent): return self._acl def add_rule( - self, - action: ACLAction, - protocol: Optional[IPProtocol] = None, - src_ip: Optional[Union[str, IPv4Address]] = None, - src_port: Optional[Port] = None, - dst_ip: Optional[Union[str, IPv4Address]] = None, - dst_port: Optional[Port] = None, - position: int = 0, + self, + action: ACLAction, + protocol: Optional[IPProtocol] = None, + src_ip: Optional[Union[str, IPv4Address]] = None, + src_port: Optional[Port] = None, + dst_ip: Optional[Union[str, IPv4Address]] = None, + dst_port: Optional[Port] = None, + position: int = 0, ) -> None: if isinstance(src_ip, str): src_ip = IPv4Address(src_ip) @@ -96,12 +96,12 @@ class AccessControlList(SimComponent): raise ValueError(f"Position {position} is out of bounds.") def is_permitted( - self, - protocol: IPProtocol, - src_ip: Union[str, IPv4Address], - src_port: Optional[Port], - dst_ip: Union[str, IPv4Address], - dst_port: Optional[Port], + self, + protocol: IPProtocol, + src_ip: Union[str, IPv4Address], + src_port: Optional[Port], + dst_ip: Union[str, IPv4Address], + dst_port: Optional[Port], ) -> Tuple[bool, Optional[Union[str, ACLRule]]]: if not isinstance(src_ip, IPv4Address): src_ip = IPv4Address(src_ip) @@ -112,23 +112,23 @@ class AccessControlList(SimComponent): continue if ( - (rule.src_ip == src_ip or rule.src_ip is None) - and (rule.dst_ip == dst_ip or rule.dst_ip is None) - and (rule.protocol == protocol or rule.protocol is None) - and (rule.src_port == src_port or rule.src_port is None) - and (rule.dst_port == dst_port or rule.dst_port is None) + (rule.src_ip == src_ip or rule.src_ip is None) + and (rule.dst_ip == dst_ip or rule.dst_ip is None) + and (rule.protocol == protocol or rule.protocol is None) + and (rule.src_port == src_port or rule.src_port is None) + and (rule.dst_port == dst_port or rule.dst_port is None) ): return rule.action == ACLAction.PERMIT, rule return self.implicit_action == ACLAction.PERMIT, f"Implicit {self.implicit_action.name}" def get_relevant_rules( - self, - protocol: IPProtocol, - src_ip: Union[str, IPv4Address], - src_port: Port, - dst_ip: Union[str, IPv4Address], - dst_port: Port, + self, + protocol: IPProtocol, + src_ip: Union[str, IPv4Address], + src_port: Port, + dst_ip: Union[str, IPv4Address], + dst_port: Port, ) -> List[ACLRule]: if not isinstance(src_ip, IPv4Address): src_ip = IPv4Address(src_ip) @@ -140,11 +140,11 @@ class AccessControlList(SimComponent): continue if ( - (rule.src_ip == src_ip or rule.src_ip is None) - or (rule.dst_ip == dst_ip or rule.dst_ip is None) - or (rule.protocol == protocol or rule.protocol is None) - or (rule.src_port == src_port or rule.src_port is None) - or (rule.dst_port == dst_port or rule.dst_port is None) + (rule.src_ip == src_ip or rule.src_ip is None) + or (rule.dst_ip == dst_ip or rule.dst_ip is None) + or (rule.protocol == protocol or rule.protocol is None) + or (rule.src_port == src_port or rule.src_port is None) + or (rule.dst_port == dst_port or rule.dst_port is None) ): relevant_rules.append(rule) @@ -247,11 +247,11 @@ class RouteTable(SimComponent): pass def add_route( - self, - address: Union[IPv4Address, str], - subnet_mask: Union[IPv4Address, str], - next_hop: Union[IPv4Address, str], - metric: float = 0.0, + self, + address: Union[IPv4Address, str], + subnet_mask: Union[IPv4Address, str], + next_hop: Union[IPv4Address, str], + metric: float = 0.0, ): """Add a route to the routing table. @@ -367,36 +367,46 @@ class RouterICMP(ICMP): # determine if request is for router interface or whether it needs to be routed for nic in self.router.nics.values(): - if nic.ip_address == frame.ip.dst_ip and nic.enabled: - # reply to the request - self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") - target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) - src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) - tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) + if nic.ip_address == frame.ip.dst_ip: + if nic.enabled: + # reply to the request + if not is_reattempt: + self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") + target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) + src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) + tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) - # Network Layer - ip_packet = IPPacket(src_ip=nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) - # Data Link Layer - ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) - icmp_reply_packet = ICMPPacket( - icmp_type=ICMPType.ECHO_REPLY, - icmp_code=0, - identifier=frame.icmp.identifier, - sequence=frame.icmp.sequence + 1, - ) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) - self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") + # Network Layer + ip_packet = IPPacket(src_ip=nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) + # Data Link Layer + ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) + icmp_reply_packet = ICMPPacket( + icmp_type=ICMPType.ECHO_REPLY, + icmp_code=0, + identifier=frame.icmp.identifier, + sequence=frame.icmp.sequence + 1, + ) + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") - src_nic.send_frame(frame) + src_nic.send_frame(frame) return # Route the frame self.router.route_frame(frame, from_nic) + elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: - self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") - if not self.request_replies.get(frame.icmp.identifier): - self.request_replies[frame.icmp.identifier] = 0 - self.request_replies[frame.icmp.identifier] += 1 + for nic in self.router.nics.values(): + if nic.ip_address == frame.ip.dst_ip: + if nic.enabled: + self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") + if not self.request_replies.get(frame.icmp.identifier): + self.request_replies[frame.icmp.identifier] = 0 + self.request_replies[frame.icmp.identifier] += 1 + + return + # Route the frame + self.router.route_frame(frame, from_nic) class Router(Node): @@ -436,25 +446,34 @@ class Router(Node): pass def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: - if not re_attempt: - # Check if src ip is on network of one of the NICs - nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip) - target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip) - if not nic: - self.arp.send_arp_request(frame.ip.dst_ip) - return self.route_frame(frame=frame, from_nic=from_nic, re_attempt=True) - for nic in self.nics.values(): - if nic.enabled and frame.ip.dst_ip in nic.ip_network: - from_port = self._get_port_of_nic(from_nic) - to_port = self._get_port_of_nic(nic) - self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") - frame.decrement_ttl() - frame.ethernet.src_mac_addr = nic.mac_address - frame.ethernet.dst_mac_addr = target_mac - nic.send_frame(frame) - return - else: + # Check if src ip is on network of one of the NICs + nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip) + target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip) + + if re_attempt and not nic: self.sys_log.info(f"Destination {frame.ip.dst_ip} is unreachable") + return + + if not nic: + self.arp.send_arp_request(frame.ip.dst_ip) + return self.route_frame(frame=frame, from_nic=from_nic, re_attempt=True) + + if not nic.enabled: + # TODO: Add sys_log here + return + + if frame.ip.dst_ip in nic.ip_network: + from_port = self._get_port_of_nic(from_nic) + to_port = self._get_port_of_nic(nic) + self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") + frame.decrement_ttl() + frame.ethernet.src_mac_addr = nic.mac_address + frame.ethernet.dst_mac_addr = target_mac + nic.send_frame(frame) + return + else: + pass + # TODO: Deal with routing from route tables def receive_frame(self, frame: Frame, from_nic: NIC): """ From 89ad22acebbd39cc6bf7a30808b104f46bd1152b Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 31 Aug 2023 13:35:56 +0100 Subject: [PATCH 13/21] #1800 - Synced with dev. - Added the UC2 network. - Added a Computer class. --- src/primaite/simulator/network/container.py | 29 +++- .../network/hardware/nodes/computer.py | 44 +++++ src/primaite/simulator/network/networks.py | 154 ++++++++++++++++++ 3 files changed, 221 insertions(+), 6 deletions(-) create mode 100644 src/primaite/simulator/network/hardware/nodes/computer.py create mode 100644 src/primaite/simulator/network/networks.py diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 85676034..ac502d84 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, Union +from typing import Any, Dict, Union, Optional from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent @@ -58,6 +58,19 @@ class Network(SimComponent): node.parent = self _LOGGER.info(f"Added node {node.uuid} to Network {self.uuid}") + def get_node_by_hostname(self, hostname: str) -> Optional[Node]: + """ + Get a Node from the Network by its hostname. + + .. note:: Assumes hostnames on the network are unique. + + :param hostname: The Node hostname. + :return: The Node if it exists in the network. + """ + for node in self.nodes.values(): + if node.hostname == hostname: + return node + def remove_node(self, node: Node) -> None: """ Remove a node from the network. @@ -72,7 +85,8 @@ class Network(SimComponent): node.parent = None _LOGGER.info(f"Removed node {node.uuid} from network {self.uuid}") - def connect(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: + def connect(self, endpoint_a: Union[Node, NIC, SwitchPort], endpoint_b: Union[Node, NIC, SwitchPort], **kwargs) -> \ + None: """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. :param endpoint_a: The endpoint to which to connect the link on the first node @@ -81,16 +95,19 @@ class Network(SimComponent): :type endpoint_b: Union[NIC, SwitchPort] :raises RuntimeError: _description_ """ - node_a = endpoint_a.parent - node_b = endpoint_b.parent + node_a: Node = endpoint_a.parent if not isinstance(endpoint_a, Node) else endpoint_a + node_b: Node = endpoint_b.parent if not isinstance(endpoint_b, Node) else endpoint_b if node_a not in self: self.add_node(node_a) if node_b not in self: self.add_node(node_b) if node_a is node_b: - _LOGGER.warn(f"Cannot link endpoint {endpoint_a} to {endpoint_b} because they belong to the same node.") + _LOGGER.warning(f"Cannot link endpoint {endpoint_a} to {endpoint_b} because they belong to the same node.") return - + if isinstance(endpoint_a, Node) and len(endpoint_a.nics) == 1: + endpoint_a = list(endpoint_a.nics.values())[0] + if isinstance(endpoint_b, Node) and len(endpoint_b.nics) == 1: + endpoint_b = list(endpoint_b.nics.values())[0] link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, **kwargs) self.links[link.uuid] = link link.parent = self diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py new file mode 100644 index 00000000..8dfb7540 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -0,0 +1,44 @@ +from ipaddress import IPv4Address + +from primaite.simulator.network.hardware.base import Node, NIC + + +class Computer(Node): + """ + A basic computer class. + + Example: + >>> pc_a = Computer( + hostname="pc_a", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + >>> pc_a.power_on() + + Instances of computer come 'pre-packaged' with the following: + + * Core Functionality: + * ARP. + * ICMP. + * Packet Capture. + * Sys Log. + * Services: + * DNS Client. + * FTP Client. + * LDAP Client. + * NTP Client. + * Applications: + * Email Client. + * Web Browser. + * Processes: + * Placeholder. + """ + + def __init__(self, **kwargs): + for key in {"ip_address", "subnet_mask", "default_gateway"}: + if key in kwargs: + if not isinstance(kwargs[key], IPv4Address): + kwargs[key] = IPv4Address(kwargs[key]) + super().__init__(**kwargs) + self.connect_nic(NIC(ip_address=kwargs["ip_address"], subnet_mask=kwargs["subnet_mask"])) diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py new file mode 100644 index 00000000..0eccefa4 --- /dev/null +++ b/src/primaite/simulator/network/networks.py @@ -0,0 +1,154 @@ +from primaite.simulator.network.container import Network +from primaite.simulator.network.hardware.base import Switch, NIC +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.router import Router, ACLAction +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port + + +def arcd_uc2_network() -> Network: + """ + Models the ARCD Use Case 2 Network. + + +------------+ + | domain_ | + +------------+ controller | + | | | + | +------------+ + | + | + +------------+ | +------------+ + | | | | | + | client_1 +---------+ | +---------+ web_server | + | | | | | | | + +------------+ | | | +------------+ + +--+---------+ +------------+ +------+--+--+ + | | | | | | + | switch_2 +------+ router_1 +------+ switch_1 | + | | | | | | + +--+------+--+ +------------+ +--+---+--+--+ + +------------+ | | | | | +------------+ + | | | | | | | | database | + | client_2 +---------+ | | | +---------+ _server | + | | | | | | | + +------------+ | | | +------------+ + | +------------+ | | + | | security | | | + +---------+ _suite +---------+ | +------------+ + | | | | backup_ | + +------------+ +------------+ server | + | | + +------------+ + + Example: + >>> network = arcd_uc2_network() + >>> network.get_node_by_hostname("client_1").ping("192.168.1.10") + + """ + network = Network() + + # Router 1 + router_1 = Router(hostname="router_1", num_ports=5) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.10.1", subnet_mask="255.255.255.0") + + # Switch 1 + switch_1 = Switch(hostname="switch_1", num_ports=8) + switch_1.power_on() + network.connect(endpoint_a=router_1.ethernet_ports[1], endpoint_b=switch_1.switch_ports[8]) + router_1.enable_port(1) + + # Switch 2 + switch_2 = Switch(hostname="switch_2", num_ports=8) + switch_2.power_on() + network.connect(endpoint_a=router_1.ethernet_ports[2], endpoint_b=switch_2.switch_ports[8]) + router_1.enable_port(2) + + # Client 1 + client_1 = Computer( + hostname="client_1", + ip_address="192.168.10.21", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1" + ) + client_1.power_on() + network.connect(endpoint_a=client_1, endpoint_b=switch_2.switch_ports[1]) + + # Client 2 + client_2 = Computer( + hostname="client_2", + ip_address="192.168.10.22", + subnet_mask="255.255.255.0", + default_gateway="192.168.10.1" + ) + client_2.power_on() + network.connect(endpoint_a=client_2, endpoint_b=switch_2.switch_ports[2]) + + # Domain Controller + domain_controller = Computer( + hostname="domain_controller", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + domain_controller.power_on() + network.connect(endpoint_a=domain_controller, endpoint_b=switch_1.switch_ports[1]) + + # Web Server + web_server = Computer( + hostname="web_server", + ip_address="192.168.1.12", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + web_server.power_on() + network.connect(endpoint_a=web_server, endpoint_b=switch_1.switch_ports[2]) + + # Database Server + database_server = Computer( + hostname="database_server", + ip_address="192.168.1.14", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + database_server.power_on() + network.connect(endpoint_a=database_server, endpoint_b=switch_1.switch_ports[3]) + + # Backup Server + backup_server = Computer( + hostname="backup_server", + ip_address="192.168.1.16", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + backup_server.power_on() + network.connect(endpoint_a=backup_server, endpoint_b=switch_1.switch_ports[4]) + + # Security Suite + security_suite = Computer( + hostname="security_suite", + ip_address="192.168.1.110", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + security_suite.power_on() + network.connect(endpoint_a=security_suite, endpoint_b=switch_1.switch_ports[7]) + security_suite_external_nic = NIC(ip_address="192.168.10.110", subnet_mask="255.255.255.0") + security_suite.connect_nic(security_suite_external_nic) + network.connect(endpoint_a=security_suite_external_nic, endpoint_b=switch_2.switch_ports[7]) + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + src_port=Port.ARP, + dst_port=Port.ARP, + position=22 + ) + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.ICMP, + position=23 + ) + + return network From 5111affeebbc8a75becbfa2c31b34eed4a8a9ebc Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 1 Sep 2023 16:58:21 +0100 Subject: [PATCH 14/21] #1800 - Added more docstrings and rst docs. - Extended the .show functionality to enable markdown format too. --- docs/source/simulation.rst | 3 + .../simulation_components/network/network.rst | 114 +++++++++ .../simulation_components/network/router.rst | 73 ++++++ .../simulation_components/network/switch.rst | 8 + src/primaite/simulator/network/container.py | 175 +++++++++++-- .../simulator/network/hardware/base.py | 62 +++-- .../network/hardware/nodes/computer.py | 24 +- .../network/hardware/nodes/router.py | 240 +++++++++++++++--- .../network/hardware/nodes/server.py | 37 +++ src/primaite/simulator/network/networks.py | 103 ++++++-- .../network/transmission/data_link_layer.py | 5 + src/primaite/simulator/system/core/sys_log.py | 17 +- 12 files changed, 753 insertions(+), 108 deletions(-) create mode 100644 docs/source/simulation_components/network/network.rst create mode 100644 docs/source/simulation_components/network/router.rst create mode 100644 docs/source/simulation_components/network/switch.rst create mode 100644 src/primaite/simulator/network/hardware/nodes/server.py diff --git a/docs/source/simulation.rst b/docs/source/simulation.rst index a2784628..7e9fe77f 100644 --- a/docs/source/simulation.rst +++ b/docs/source/simulation.rst @@ -18,3 +18,6 @@ Contents simulation_structure simulation_components/network/base_hardware simulation_components/network/transport_to_data_link_layer + simulation_components/network/router + simulation_components/network/switch + simulation_components/network/network diff --git a/docs/source/simulation_components/network/network.rst b/docs/source/simulation_components/network/network.rst new file mode 100644 index 00000000..e5614980 --- /dev/null +++ b/docs/source/simulation_components/network/network.rst @@ -0,0 +1,114 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _about: + +Network +======= + +The ``Network`` class serves as the backbone of the simulation. It offers a framework to manage various network +components such as routers, switches, servers, and clients. This document provides a detailed explanation of how to +effectively use the ``Network`` class. + +Example Usage +------------- + +Below demonstrates how to use the Router node to connect Nodes, and block traffic using ACLs. For this demonstration, +we'll use the following Network that has a client, server, two switches, and a router. + +.. code-block:: text + + +------------+ +------------+ +------------+ +------------+ +------------+ + | | | | | | | | | | + | client_1 +------+ switch_2 +------+ router_1 +------+ switch_1 +------+ server_1 | + | | | | | | | | | | + +------------+ +------------+ +------------+ +------------+ +------------+ + +1. Relevant imports + +.. code-block:: python + + from primaite.simulator.network.container import Network + from primaite.simulator.network.hardware.base import Switch, NIC + from primaite.simulator.network.hardware.nodes.computer import Computer + from primaite.simulator.network.hardware.nodes.router import Router, ACLAction + from primaite.simulator.network.hardware.nodes.server import Server + from primaite.simulator.network.transmission.network_layer import IPProtocol + from primaite.simulator.network.transmission.transport_layer import Port + +2. Create the Network + +.. code-block:: python + + network = Network() + +3. Create and configure the Router + +.. code-block:: python + + router_1 = Router(hostname="router_1", num_ports=3) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.2.1", subnet_mask="255.255.255.0") + +4. Create and configure the two Switches + +.. code-block:: python + + switch_1 = Switch(hostname="switch_1", num_ports=6) + switch_1.power_on() + switch_2 = Switch(hostname="switch_2", num_ports=6) + switch_2.power_on() + +5. Connect the Switches to the Router + +.. code-block:: python + + network.connect(endpoint_a=router_1.ethernet_ports[1], endpoint_b=switch_1.switch_ports[6]) + router_1.enable_port(1) + network.connect(endpoint_a=router_1.ethernet_ports[2], endpoint_b=switch_2.switch_ports[6]) + router_1.enable_port(2) + +6. Create the Client and Server nodes. + +.. code-block:: python + + client_1 = Computer( + hostname="client_1", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1" + ) + client_1.power_on() + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + server_1.power_on() + +7. Connect the Client and Server to the relevant Switch + +.. code-block:: python + + network.connect(endpoint_a=switch_2.switch_ports[1], endpoint_b=client_1.ethernet_port[1]) + network.connect(endpoint_a=switch_1.switch_ports[1], endpoint_b=server_1.ethernet_port[1]) + +8. Add ACL rules on the Router to allow ARP and ICMP traffic. + +.. code-block:: python + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + src_port=Port.ARP, + dst_port=Port.ARP, + position=22 + ) + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.ICMP, + position=23 + ) diff --git a/docs/source/simulation_components/network/router.rst b/docs/source/simulation_components/network/router.rst new file mode 100644 index 00000000..aaa589cc --- /dev/null +++ b/docs/source/simulation_components/network/router.rst @@ -0,0 +1,73 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _about: + +Router Module +============= + +The router module contains classes for simulating the functions of a network router. + +Router +------ + +The Router class represents a multi-port network router that can receive, process, and route network packets between its ports and other Nodes + +The router maintains internal state including: + +- RouteTable - Routing table to lookup where to forward packets. +- AccessControlList - Access control rules to block or allow packets. +- ARP cache - MAC address lookups for connected devices. +- ICMP handler - Handles ICMP requests to router interfaces. + +The router receives incoming frames on enabled ports. It processes the frame headers and applies the following logic: + +1. Checks the AccessControlList if the packet is permitted. If blocked, it is dropped. +2. For permitted packets, routes the frame based on: + - ARP cache lookups for destination MAC address. + - ICMP echo requests handled directly. + - RouteTable lookup to forward packet out other ports. +3. Updates ARP cache as it learns new information about the Network. + + + +RouteTable +---------- + +The RouteTable holds RouteEntry objects representing routes. It finds the best route for a destination IP using a metric and the longest prefix match algorithm. + +Routes can be added and looked up based on destination IP address. The RouteTable is used by the Router when forwarding packets between other Nodes. + +AccessControlList +----------------- + +The AccessControlList defines Access Control Rules to block or allow packets. Packets are checked against the rules to determine if they are permitted to be forwarded. + +Rules can be added to deny or permit traffic based on IP, port, and protocol. The ACL is checked by the Router when packets are received. + +Packet Processing +----------------- + +-The Router supports the following protocols and packet types: + +ARP +^^^ + +- Handles both ARP requests and responses. +- Updates ARP cache. +- Proxies ARP replies for connected networks. +- Routes ARP requests. + +ICMP +^^^^ + +- Responds to ICMP echo requests to Router interfaces. +- Routes other ICMP messages based on routes. + +TCP/UDP +^^^^^^^ + +- Forwards packets based on routes like IP. +- Applies ACL rules based on protocol, source/destination IP address, and source/destination port numbers. +- Decrements TTL and drops expired TTL packets. diff --git a/docs/source/simulation_components/network/switch.rst b/docs/source/simulation_components/network/switch.rst new file mode 100644 index 00000000..4b3b24bc --- /dev/null +++ b/docs/source/simulation_components/network/switch.rst @@ -0,0 +1,8 @@ +.. only:: comment + + © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK + +.. _about: + +Switch +====== \ No newline at end of file diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index ac502d84..ccb9ce77 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,20 +1,41 @@ -from typing import Any, Dict, Union, Optional +from typing import Any, Dict, Union, Optional, List + +import matplotlib.pyplot as plt +import networkx as nx +from networkx import MultiGraph +from prettytable import PrettyTable, MARKDOWN from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent -from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort +from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort, Switch +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.router import Router +from primaite.simulator.network.hardware.nodes.server import Server _LOGGER = getLogger(__name__) class Network(SimComponent): - """Top level container object representing the physical network.""" + """ + Top level container object representing the physical network. + + This class manages nodes, links, and other network components. It also + offers methods for rendering the network topology and gathering states. + + :ivar Dict[str, Node] nodes: Dictionary mapping node UUIDs to Node instances. + :ivar Dict[str, Link] links: Dictionary mapping link UUIDs to Link instances. + """ nodes: Dict[str, Node] = {} links: Dict[str, Link] = {} def __init__(self, **kwargs): - """Initialise the network.""" + """" + Initialise the network. + + Constructs the network and sets up its initial state including + the action manager and an empty MultiGraph for topology representation. + """ super().__init__(**kwargs) self.action_manager = ActionManager() @@ -25,15 +46,112 @@ class Network(SimComponent): validator=AllowAllValidator(), ), ) + self._nx_graph = MultiGraph() + + @property + def routers(self) -> List[Router]: + """The Routers in the Network.""" + return [node for node in self.nodes.values() if isinstance(node, Router)] + + @property + def switches(self) -> List[Switch]: + """The Switches in the Network.""" + return [node for node in self.nodes.values() if isinstance(node, Switch)] + + @property + def computers(self) -> List[Computer]: + """The Computers in the Network.""" + return [node for node in self.nodes.values() if isinstance(node, Computer) and not isinstance(node, Server)] + + @property + def servers(self) -> List[Server]: + """The Servers in the Network.""" + return [node for node in self.nodes.values() if isinstance(node, Server)] + + def show(self, nodes: bool = True, ip_addresses: bool = True, links: bool = True, markdown: bool = False): + """ + Print tables describing the Network. + + Generate and print PrettyTable instances that show details about nodes, + IP addresses, and links in the network. Output can be in Markdown format. + + :param nodes: Include node details in the output. Defaults to True. + :param ip_addresses: Include IP address details in the output. Defaults to True. + :param links: Include link details in the output. Defaults to True. + :param markdown: Use Markdown style in table output. Defaults to False. + """ + nodes_type_map = { + "Router": self.routers, + "Switch": self.switches, + "Server": self.servers, + "Computer": self.computers + } + if nodes: + table = PrettyTable(["Node", "Type", "Operating State"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"Nodes" + for node_type, nodes in nodes_type_map.items(): + for node in nodes: + table.add_row([node.hostname, node_type, node.operating_state.name]) + print(table) + + if ip_addresses: + table = PrettyTable(["Node", "Port", "IP Address", "Subnet Mask", "Default Gateway"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"IP Addresses" + for nodes in nodes_type_map.values(): + for node in nodes: + for i, port in node.ethernet_port.items(): + table.add_row([node.hostname, i, port.ip_address, port.subnet_mask, node.default_gateway]) + print(table) + + if links: + table = PrettyTable(["Endpoint A", "Endpoint B", "is Up", "Bandwidth (MBits)", "Current Load"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"Links" + links = list(self.links.values()) + for nodes in nodes_type_map.values(): + for node in nodes: + for link in links[::-1]: + if node in [link.endpoint_a.parent, link.endpoint_b.parent]: + table.add_row( + [ + link.endpoint_a.parent.hostname, + link.endpoint_b.parent.hostname, + link.is_up, + link.bandwidth, + link.current_load_percent + ] + ) + links.remove(link) + print(table) + + def clear_links(self): + """Clear all the links in the network by resetting their component state for the episode.""" + for link in self.links.values(): + link.reset_component_for_episode() + + def draw(self, seed: int = 123): + """ + Draw the Network using NetworkX and matplotlib.pyplot. + + :param seed: An integer seed for reproducible layouts. Default is 123. + """ + pos = nx.spring_layout(self._nx_graph, seed=seed) + nx.draw(self._nx_graph, pos, with_labels=True) + plt.show() def describe_state(self) -> Dict: """ - Produce a dictionary describing the current state of this object. + Produce a dictionary describing the current state of the Network. - Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - - :return: Current state of this object and child objects. - :rtype: Dict + :return: A dictionary capturing the current state of the Network and its child objects. """ state = super().describe_state() state.update( @@ -48,14 +166,16 @@ class Network(SimComponent): """ Add an existing node to the network. - :param node: Node instance that the network should keep track of. - :type node: Node + .. note:: If the node is already present in the network, a warning is logged. + + :param node: Node instance that should be kept track of by the network. """ if node in self: _LOGGER.warning(f"Can't add node {node.uuid}. It is already in the network.") return self.nodes[node.uuid] = node node.parent = self + self._nx_graph.add_node(node.hostname) _LOGGER.info(f"Added node {node.uuid} to Network {self.uuid}") def get_node_by_hostname(self, hostname: str) -> Optional[Node]: @@ -75,6 +195,8 @@ class Network(SimComponent): """ Remove a node from the network. + .. note:: If the node is not found in the network, a warning is logged. + :param node: Node instance that is currently part of the network that should be removed. :type node: Node """ @@ -85,18 +207,22 @@ class Network(SimComponent): node.parent = None _LOGGER.info(f"Removed node {node.uuid} from network {self.uuid}") - def connect(self, endpoint_a: Union[Node, NIC, SwitchPort], endpoint_b: Union[Node, NIC, SwitchPort], **kwargs) -> \ - None: - """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. - - :param endpoint_a: The endpoint to which to connect the link on the first node - :type endpoint_a: Union[NIC, SwitchPort] - :param endpoint_b: The endpoint to which to connct the link on the second node - :type endpoint_b: Union[NIC, SwitchPort] - :raises RuntimeError: _description_ + def connect( + self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs + ) -> None: """ - node_a: Node = endpoint_a.parent if not isinstance(endpoint_a, Node) else endpoint_a - node_b: Node = endpoint_b.parent if not isinstance(endpoint_b, Node) else endpoint_b + Connect two endpoints on the network by creating a link between their NICs/SwitchPorts. + + .. note:: If the nodes owning the endpoints are not already in the network, they are automatically added. + + :param endpoint_a: The first endpoint to connect. + :type endpoint_a: Union[NIC, SwitchPort] + :param endpoint_b: The second endpoint to connect. + :type endpoint_b: Union[NIC, SwitchPort] + :raises RuntimeError: If any validation or runtime checks fail. + """ + node_a: Node = endpoint_a.parent + node_b: Node = endpoint_b.parent if node_a not in self: self.add_node(node_a) if node_b not in self: @@ -104,12 +230,9 @@ class Network(SimComponent): if node_a is node_b: _LOGGER.warning(f"Cannot link endpoint {endpoint_a} to {endpoint_b} because they belong to the same node.") return - if isinstance(endpoint_a, Node) and len(endpoint_a.nics) == 1: - endpoint_a = list(endpoint_a.nics.values())[0] - if isinstance(endpoint_b, Node) and len(endpoint_b.nics) == 1: - endpoint_b = list(endpoint_b.nics.values())[0] link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, **kwargs) self.links[link.uuid] = link + self._nx_graph.add_edge(endpoint_a.parent.hostname, endpoint_b.parent.hostname) link.parent = self _LOGGER.info(f"Added link {link.uuid} to connect {endpoint_a} and {endpoint_b}") diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 9834d439..674020ee 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1,12 +1,13 @@ from __future__ import annotations +import random import re import secrets from enum import Enum from ipaddress import IPv4Address, IPv4Network from typing import Dict, List, Optional, Tuple, Union -from prettytable import PrettyTable +from prettytable import PrettyTable, MARKDOWN from primaite import getLogger from primaite.exceptions import NetworkError @@ -256,7 +257,6 @@ class NIC(SimComponent): The Frame is passed to the Node. :param frame: The network frame being received. - :type frame: :class:`~primaite.simulator.network.osi_layers.Frame` """ if self.enabled: frame.decrement_ttl() @@ -266,9 +266,6 @@ class NIC(SimComponent): if frame.ethernet.dst_mac_addr == self.mac_address or frame.ethernet.dst_mac_addr == "ff:ff:ff:ff:ff:ff": self.connected_node.receive_frame(frame=frame, from_nic=self) return True - else: - self.connected_node.sys_log.info("Dropping frame not for me") - print(frame) return False def __str__(self) -> str: @@ -562,9 +559,12 @@ class ARPCache: self.arp: Dict[IPv4Address, ARPEntry] = {} self.nics: Dict[str, "NIC"] = {} - def show(self): + def show(self, markdown: bool = False): """Prints a table of ARC Cache.""" table = PrettyTable(["IP Address", "MAC Address", "Via"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" table.title = f"{self.sys_log.hostname} ARP Cache" for ip, arp in self.arp.items(): table.add_row( @@ -765,12 +765,22 @@ class ICMP: identifier=frame.icmp.identifier, sequence=frame.icmp.sequence + 1, ) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) + payload = secrets.token_urlsafe(int(32/1.3)) # Standard ICMP 32 bytes size + frame = Frame( + ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload + ) self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") src_nic.send_frame(frame) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: - self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") + time = frame.transmission_duration() + time_str = f"{time}ms" if time > 0 else "<1ms" + self.sys_log.info( + f"Reply from {frame.ip.src_ip}: " + f"bytes={len(frame.payload)}, " + f"time={time_str}, " + f"TTL={frame.ip.ttl}" + ) if not self.request_replies.get(frame.icmp.identifier): self.request_replies[frame.icmp.identifier] = 0 self.request_replies[frame.icmp.identifier] += 1 @@ -819,8 +829,8 @@ class ICMP: # Data Link Layer ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet) - self.sys_log.info(f"Sending echo request to {target_ip_address}") + payload = secrets.token_urlsafe(int(32/1.3)) # Standard ICMP 32 bytes size + frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet, payload=payload) nic.send_frame(frame) return sequence, icmp_packet.identifier @@ -857,6 +867,8 @@ class Node(SimComponent): "The hardware state of the node." nics: Dict[str, NIC] = {} "The NICs on the node." + ethernet_port: Dict[int, NIC] = {} + "The NICs on the node by port id." accounts: Dict[str, Account] = {} "All accounts on the node." @@ -928,13 +940,17 @@ class Node(SimComponent): ) return state - def show(self): + def show(self, markdown: bool = False): """Prints a table of the NICs on the Node.""" - table = PrettyTable(["MAC Address", "Address", "Speed", "Status"]) + table = PrettyTable(["Port", "MAC Address", "Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" table.title = f"{self.hostname} Network Interface Cards" - for nic in self.nics.values(): + for port, nic in self.ethernet_port.items(): table.add_row( [ + port, nic.mac_address, f"{nic.ip_address}/{nic.ip_network.prefixlen}", nic.speed, @@ -969,6 +985,7 @@ class Node(SimComponent): """ if nic.uuid not in self.nics: self.nics[nic.uuid] = nic + self.ethernet_port[len(self.nics)] = nic nic.connected_node = self nic.parent = self self.sys_log.info(f"Connected NIC {nic}") @@ -990,6 +1007,10 @@ class Node(SimComponent): if isinstance(nic, str): nic = self.nics.get(nic) if nic or nic.uuid in self.nics: + for port, _nic in self.ethernet_port.items(): + if nic == _nic: + self.ethernet_port.pop(port) + break self.nics.pop(nic.uuid) nic.parent = None nic.disable() @@ -1014,7 +1035,7 @@ class Node(SimComponent): self.sys_log.info("Pinging loopback address") return any(nic.enabled for nic in self.nics.values()) if self.operating_state == NodeOperatingState.ON: - self.sys_log.info(f"Attempting to ping {target_ip_address}") + self.sys_log.info(f"Pinging {target_ip_address}:") sequence, identifier = 0, None while sequence < pings: sequence, identifier = self.icmp.ping(target_ip_address, sequence, identifier, pings) @@ -1022,8 +1043,14 @@ class Node(SimComponent): passed = request_replies == pings if request_replies: self.icmp.request_replies.pop(identifier) + else: + request_replies = 0 + self.sys_log.info( + f"Ping statistics for {target_ip_address}: " + f"Packets: Sent = {pings}, " + f"Received = {request_replies}, " + f"Lost = {pings-request_replies} ({(pings-request_replies)/pings*100}% loss)") return passed - self.sys_log.info("Ping failed as the node is turned off") return False def send_frame(self, frame: Frame): @@ -1078,9 +1105,12 @@ class Switch(Node): port.parent = self port.port_num = port_num - def show(self): + def show(self, markdown: bool = False): """Prints a table of the SwitchPorts on the Switch.""" table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" table.title = f"{self.hostname} Switch Ports" for port_num, port in self.switch_ports.items(): table.add_row([port_num, port.mac_address, port.speed, "Enabled" if port.enabled else "Disabled"]) diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index 8dfb7540..110ad385 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -5,7 +5,7 @@ from primaite.simulator.network.hardware.base import Node, NIC class Computer(Node): """ - A basic computer class. + A basic Computer class. Example: >>> pc_a = Computer( @@ -19,20 +19,20 @@ class Computer(Node): Instances of computer come 'pre-packaged' with the following: * Core Functionality: - * ARP. - * ICMP. - * Packet Capture. - * Sys Log. + * ARP + * ICMP + * Packet Capture + * Sys Log * Services: - * DNS Client. - * FTP Client. - * LDAP Client. - * NTP Client. + * DNS Client + * FTP Client + * LDAP Client + * NTP Client * Applications: - * Email Client. - * Web Browser. + * Email Client + * Web Browser * Processes: - * Placeholder. + * Placeholder """ def __init__(self, **kwargs): diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 7db92938..b507143b 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -1,10 +1,11 @@ from __future__ import annotations +import secrets from enum import Enum from ipaddress import IPv4Address, IPv4Network from typing import Dict, List, Optional, Tuple, Union -from prettytable import PrettyTable +from prettytable import PrettyTable, MARKDOWN from primaite.simulator.core import SimComponent from primaite.simulator.network.hardware.base import ARPCache, ICMP, NIC, Node @@ -22,8 +23,16 @@ class ACLAction(Enum): class ACLRule(SimComponent): - def describe_state(self) -> Dict: - pass + """ + Represents an Access Control List (ACL) rule. + + :ivar ACLAction action: Action to be performed (Permit/Deny). Default is DENY. + :ivar Optional[IPProtocol] protocol: Network protocol. Default is None. + :ivar Optional[IPv4Address] src_ip: Source IP address. Default is None. + :ivar Optional[Port] src_port: Source port number. Default is None. + :ivar Optional[IPv4Address] dst_ip: Destination IP address. Default is None. + :ivar Optional[Port] dst_port: Destination port number. Default is None. + """ action: ACLAction = ACLAction.DENY protocol: Optional[IPProtocol] = None @@ -43,8 +52,25 @@ class ACLRule(SimComponent): rule_strings.append(f"{key}={value}") return ", ".join(rule_strings) + def describe_state(self) -> Dict: + """ + Describes the current state of the ACLRule. + + :return: A dictionary representing the current state. + """ + pass + class AccessControlList(SimComponent): + """ + Manages a list of ACLRules to filter network traffic. + + :ivar SysLog sys_log: System logging instance. + :ivar ACLAction implicit_action: Default action for rules. + :ivar ACLRule implicit_rule: Implicit ACL rule, created based on implicit_action. + :ivar int max_acl_rules: Maximum number of ACL rules that can be added. Default is 25. + :ivar List[Optional[ACLRule]] _acl: A list containing the ACL rules. + """ sys_log: SysLog implicit_action: ACLAction implicit_rule: ACLRule @@ -62,10 +88,20 @@ class AccessControlList(SimComponent): super().__init__(**kwargs) def describe_state(self) -> Dict: + """ + Describes the current state of the AccessControlList. + + :return: A dictionary representing the current state. + """ pass @property def acl(self) -> List[Optional[ACLRule]]: + """ + Get the list of ACL rules. + + :return: The list of ACL rules. + """ return self._acl def add_rule( @@ -78,6 +114,18 @@ class AccessControlList(SimComponent): dst_port: Optional[Port] = None, position: int = 0, ) -> None: + """ + Add a new ACL rule. + + :param ACLAction action: Action to be performed (Permit/Deny). + :param Optional[IPProtocol] protocol: Network protocol. + :param Optional[Union[str, IPv4Address]] src_ip: Source IP address. + :param Optional[Port] src_port: Source port number. + :param Optional[Union[str, IPv4Address]] dst_ip: Destination IP address. + :param Optional[Port] dst_port: Destination port number. + :param int position: Position in the ACL list to insert the rule. + :raises ValueError: When the position is out of bounds. + """ if isinstance(src_ip, str): src_ip = IPv4Address(src_ip) if isinstance(dst_ip, str): @@ -90,6 +138,12 @@ class AccessControlList(SimComponent): raise ValueError(f"Position {position} is out of bounds.") def remove_rule(self, position: int) -> None: + """ + Remove an ACL rule from a specific position. + + :param int position: The position of the rule to be removed. + :raises ValueError: When the position is out of bounds. + """ if 0 <= position < self.max_acl_rules: self._acl[position] = None else: @@ -103,6 +157,17 @@ class AccessControlList(SimComponent): dst_ip: Union[str, IPv4Address], dst_port: Optional[Port], ) -> Tuple[bool, Optional[Union[str, ACLRule]]]: + """ + Check if a packet with the given properties is permitted through the ACL. + + :param protocol: The protocol of the packet. + :param src_ip: Source IP address of the packet. Accepts string and IPv4Address. + :param src_port: Source port of the packet. Optional. + :param dst_ip: Destination IP address of the packet. Accepts string and IPv4Address. + :param dst_port: Destination port of the packet. Optional. + :return: A tuple with a boolean indicating if the packet is permitted and an optional rule or implicit action + string. + """ if not isinstance(src_ip, IPv4Address): src_ip = IPv4Address(src_ip) if not isinstance(dst_ip, IPv4Address): @@ -130,6 +195,16 @@ class AccessControlList(SimComponent): dst_ip: Union[str, IPv4Address], dst_port: Port, ) -> List[ACLRule]: + """ + Get the list of relevant rules for a packet with given properties. + + :param protocol: The protocol of the packet. + :param src_ip: Source IP address of the packet. Accepts string and IPv4Address. + :param src_port: Source port of the packet. + :param dst_ip: Destination IP address of the packet. Accepts string and IPv4Address. + :param dst_port: Destination port of the packet. + :return: A list of relevant ACLRules. + """ if not isinstance(src_ip, IPv4Address): src_ip = IPv4Address(src_ip) if not isinstance(dst_ip, IPv4Address): @@ -150,17 +225,16 @@ class AccessControlList(SimComponent): return relevant_rules - def show(self): - """Prints a table of the routes in the RouteTable.""" + def show(self, markdown: bool = False): + """ + Display the current ACL rules as a table. + + :param markdown: Whether to display the table in Markdown format. Defaults to False. """ - action: ACLAction - protocol: Optional[IPProtocol] - src_ip: Optional[IPv4Address] - src_port: Optional[Port] - dst_ip: Optional[IPv4Address] - dst_port: Optional[Port] - """ table = PrettyTable(["Index", "Action", "Protocol", "Src IP", "Src Port", "Dst IP", "Dst Port"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" table.title = f"{self.sys_log.hostname} Access Control List" for index, rule in enumerate(self.acl + [self.implicit_rule]): if rule: @@ -213,6 +287,11 @@ class RouteEntry(SimComponent): super().__init__(**kwargs) def describe_state(self) -> Dict: + """ + Describes the current state of the RouteEntry. + + :return: A dictionary representing the current state. + """ pass @@ -220,12 +299,7 @@ class RouteTable(SimComponent): """ Represents a routing table holding multiple route entries. - Attributes: - routes (List[RouteEntry]): A list of RouteEntry objects. - - Methods: - add_route: Add a route to the routing table. - find_best_route: Find the best route for a given destination IP. + :ivar List[RouteEntry] routes: A list of RouteEntry objects. Example: >>> rt = RouteTable() @@ -244,6 +318,11 @@ class RouteTable(SimComponent): sys_log: SysLog def describe_state(self) -> Dict: + """ + Describes the current state of the RouteTable. + + :return: A dictionary representing the current state. + """ pass def add_route( @@ -253,9 +332,13 @@ class RouteTable(SimComponent): next_hop: Union[IPv4Address, str], metric: float = 0.0, ): - """Add a route to the routing table. + """ + Add a route to the routing table. - :param route: A RouteEntry object representing the route. + :param address: The destination address of the route. + :param subnet_mask: The subnet mask of the route. + :param next_hop: The next hop IP for the route. + :param metric: The metric of the route, default is 0.0. """ for key in {address, subnet_mask, next_hop}: if not isinstance(key, IPv4Address): @@ -267,10 +350,10 @@ class RouteTable(SimComponent): """ Find the best route for a given destination IP. - :param destination_ip: The destination IPv4Address to find the route for. - :return: The best matching RouteEntry, or None if no route matches. + This method uses the Longest Prefix Match algorithm and considers metrics to find the best route. - The algorithm uses Longest Prefix Match and considers metrics to find the best route. + :param destination_ip: The destination IP to find the route for. + :return: The best matching RouteEntry, or None if no route matches. """ if not isinstance(destination_ip, IPv4Address): destination_ip = IPv4Address(destination_ip) @@ -290,9 +373,16 @@ class RouteTable(SimComponent): return best_route - def show(self): - """Prints a table of the routes in the RouteTable.""" + def show(self, markdown: bool = False): + """ + Display the current routing table as a table. + + :param markdown: Whether to display the table in Markdown format. Defaults to False. + """ table = PrettyTable(["Index", "Address", "Next Hop", "Metric"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" table.title = f"{self.sys_log.hostname} Route Table" for index, route in enumerate(self.routes): network = IPv4Network(f"{route.address}/{route.subnet_mask}") @@ -301,6 +391,12 @@ class RouteTable(SimComponent): class RouterARPCache(ARPCache): + """ + Inherits from ARPCache and adds router-specific ARP packet processing. + + :ivar SysLog sys_log: A system log for logging messages. + :ivar Router router: The router to which this ARP cache belongs. + """ def __init__(self, sys_log: SysLog, router: Router): super().__init__(sys_log) self.router: Router = router @@ -310,7 +406,7 @@ class RouterARPCache(ARPCache): Overridden method to process a received ARP packet in a router-specific way. :param from_nic: The NIC that received the ARP packet. - :param frame: The original arp frame. + :param frame: The original ARP frame. """ arp_packet = frame.arp @@ -356,6 +452,16 @@ class RouterARPCache(ARPCache): class RouterICMP(ICMP): + """ + A class to represent a router's Internet Control Message Protocol (ICMP) handler. + + :param sys_log: System log for logging network events and errors. + :type sys_log: SysLog + :param arp_cache: The ARP cache for resolving MAC addresses. + :type arp_cache: ARPCache + :param router: The router to which this ICMP handler belongs. + :type router: Router + """ router: Router def __init__(self, sys_log: SysLog, arp_cache: ARPCache, router: Router): @@ -363,6 +469,13 @@ class RouterICMP(ICMP): self.router = router def process_icmp(self, frame: Frame, from_nic: NIC, is_reattempt: bool = False): + """ + Process incoming ICMP frames based on ICMP type. + + :param frame: The incoming frame to process. + :param from_nic: The network interface where the frame is coming from. + :param is_reattempt: Flag to indicate if the process is a reattempt. + """ if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: # determine if request is for router interface or whether it needs to be routed @@ -386,7 +499,10 @@ class RouterICMP(ICMP): identifier=frame.icmp.identifier, sequence=frame.icmp.sequence + 1, ) - frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet) + payload = secrets.token_urlsafe(int(32/1.3)) # Standard ICMP 32 bytes size + frame = Frame( + ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload + ) self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") src_nic.send_frame(frame) @@ -399,7 +515,14 @@ class RouterICMP(ICMP): for nic in self.router.nics.values(): if nic.ip_address == frame.ip.dst_ip: if nic.enabled: - self.sys_log.info(f"Received echo reply from {frame.ip.src_ip}") + time = frame.transmission_duration() + time_str = f"{time}ms" if time > 0 else "<1ms" + self.sys_log.info( + f"Reply from {frame.ip.src_ip}: " + f"bytes={len(frame.payload)}, " + f"time={time_str}, " + f"TTL={frame.ip.ttl}" + ) if not self.request_replies.get(frame.icmp.identifier): self.request_replies[frame.icmp.identifier] = 0 self.request_replies[frame.icmp.identifier] += 1 @@ -410,6 +533,13 @@ class RouterICMP(ICMP): class Router(Node): + """ + A class to represent a network router node. + + :ivar str hostname: The name of the router node. + :ivar int num_ports: The number of ports in the router. + :ivar dict kwargs: Optional keyword arguments for SysLog, ACL, RouteTable, RouterARPCache, RouterICMP. + """ num_ports: int ethernet_ports: Dict[int, NIC] = {} acl: AccessControlList @@ -438,14 +568,32 @@ class Router(Node): self.icmp.arp = self.arp def _get_port_of_nic(self, target_nic: NIC) -> Optional[int]: + """ + Retrieve the port number for a given NIC. + + :param target_nic: Target network interface. + :return: The port number if NIC is found, otherwise None. + """ for port, nic in self.ethernet_ports.items(): if nic == target_nic: return port def describe_state(self) -> Dict: + """ + Describes the current state of the Router. + + :return: A dictionary representing the current state. + """ pass def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: + """ + Route a given frame from a source NIC to its destination. + + :param frame: The frame to be routed. + :param from_nic: The source network interface. + :param re_attempt: Flag to indicate if the routing is a reattempt. + """ # Check if src ip is on network of one of the NICs nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip) target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip) @@ -477,13 +625,10 @@ class Router(Node): def receive_frame(self, frame: Frame, from_nic: NIC): """ - Receive a Frame from the connected NIC and process it. + Receive a frame from a NIC and processes it based on its protocol. - Depending on the protocol, the frame is passed to the appropriate handler such as ARP or ICMP, or up to the - SessionManager if no code manager exists. - - :param frame: The Frame being received. - :param from_nic: The NIC that received the frame. + :param frame: The incoming frame. + :param from_nic: The network interface where the frame is coming from. """ route_frame = False protocol = frame.ip.protocol @@ -520,6 +665,13 @@ class Router(Node): self.route_frame(frame, from_nic) def configure_port(self, port: int, ip_address: Union[IPv4Address, str], subnet_mask: Union[IPv4Address, str]): + """ + Configure the IP settings of a given port. + + :param port: The port to configure. + :param ip_address: The IP address to set. + :param subnet_mask: The subnet mask to set. + """ if not isinstance(ip_address, IPv4Address): ip_address = IPv4Address(ip_address) if not isinstance(subnet_mask, IPv4Address): @@ -530,18 +682,36 @@ class Router(Node): self.sys_log.info(f"Configured port {port} with ip_address={ip_address}/{nic.ip_network.prefixlen}") def enable_port(self, port: int): + """ + Enable a given port on the router. + + :param port: The port to enable. + """ nic = self.ethernet_ports.get(port) if nic: nic.enable() def disable_port(self, port: int): + """ + Disable a given port on the router. + + :param port: The port to disable. + """ nic = self.ethernet_ports.get(port) if nic: nic.disable() - def show(self): + def show(self, markdown: bool = False): + """ + Prints the state of the Ethernet interfaces on the Router. + + :param markdown: Flag to indicate if the output should be in markdown format. + """ """Prints a table of the NICs on the Node.""" table = PrettyTable(["Port", "MAC Address", "Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" table.title = f"{self.hostname} Ethernet Interfaces" for port, nic in self.ethernet_ports.items(): table.add_row( diff --git a/src/primaite/simulator/network/hardware/nodes/server.py b/src/primaite/simulator/network/hardware/nodes/server.py new file mode 100644 index 00000000..a3e6f2d7 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/server.py @@ -0,0 +1,37 @@ +from ipaddress import IPv4Address + +from primaite.simulator.network.hardware.base import Node, NIC +from primaite.simulator.network.hardware.nodes.computer import Computer + + +class Server(Computer): + """ + A basic Server class. + + Example: + >>> server_a = Server( + hostname="server_a", + ip_address="192.168.1.10", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + >>> server_a.power_on() + + Instances of Server come 'pre-packaged' with the following: + + * Core Functionality: + * ARP + * ICMP + * Packet Capture + * Sys Log + * Services: + * DNS Client + * FTP Client + * LDAP Client + * NTP Client + * Applications: + * Email Client + * Web Browser + * Processes: + * Placeholder + """ diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 0eccefa4..28e58ca4 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -2,10 +2,80 @@ from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.base import Switch, NIC from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import Router, ACLAction +from primaite.simulator.network.hardware.nodes.server import Server from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port +def client_server_routed() -> Network: + """ + A basic Client/Server Network routed between subnets. + + +------------+ +------------+ +------------+ +------------+ +------------+ + | | | | | | | | | | + | client_1 +------+ switch_2 +------+ router_1 +------+ switch_1 +------+ server_1 | + | | | | | | | | | | + +------------+ +------------+ +------------+ +------------+ +------------+ + + IP Table: + + """ + network = Network() + + # Router 1 + router_1 = Router(hostname="router_1", num_ports=3) + router_1.power_on() + router_1.configure_port(port=1, ip_address="192.168.1.1", subnet_mask="255.255.255.0") + router_1.configure_port(port=2, ip_address="192.168.2.1", subnet_mask="255.255.255.0") + + # Switch 1 + switch_1 = Switch(hostname="switch_1", num_ports=6) + switch_1.power_on() + network.connect(endpoint_a=router_1.ethernet_ports[1], endpoint_b=switch_1.switch_ports[6]) + router_1.enable_port(1) + + # Switch 2 + switch_2 = Switch(hostname="switch_2", num_ports=6) + switch_2.power_on() + network.connect(endpoint_a=router_1.ethernet_ports[2], endpoint_b=switch_2.switch_ports[6]) + router_1.enable_port(2) + + # Client 1 + client_1 = Computer( + hostname="client_1", + ip_address="192.168.2.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.2.1" + ) + client_1.power_on() + network.connect(endpoint_b=client_1.ethernet_port[1], endpoint_a=switch_2.switch_ports[1]) + + # Server 1 + server_1 = Server( + hostname="server_1", + ip_address="192.168.1.2", + subnet_mask="255.255.255.0", + default_gateway="192.168.1.1" + ) + server_1.power_on() + network.connect(endpoint_b=server_1.ethernet_port[1], endpoint_a=switch_1.switch_ports[1]) + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + src_port=Port.ARP, + dst_port=Port.ARP, + position=22 + ) + + router_1.acl.add_rule( + action=ACLAction.PERMIT, + protocol=IPProtocol.ICMP, + position=23 + ) + + return network + + def arcd_uc2_network() -> Network: """ Models the ARCD Use Case 2 Network. @@ -40,9 +110,7 @@ def arcd_uc2_network() -> Network: | | +------------+ - Example: - >>> network = arcd_uc2_network() - >>> network.get_node_by_hostname("client_1").ping("192.168.1.10") + """ network = Network() @@ -73,7 +141,7 @@ def arcd_uc2_network() -> Network: default_gateway="192.168.10.1" ) client_1.power_on() - network.connect(endpoint_a=client_1, endpoint_b=switch_2.switch_ports[1]) + network.connect(endpoint_b=client_1.ethernet_port[1], endpoint_a=switch_2.switch_ports[1]) # Client 2 client_2 = Computer( @@ -83,60 +151,59 @@ def arcd_uc2_network() -> Network: default_gateway="192.168.10.1" ) client_2.power_on() - network.connect(endpoint_a=client_2, endpoint_b=switch_2.switch_ports[2]) + network.connect(endpoint_b=client_2.ethernet_port[1], endpoint_a=switch_2.switch_ports[2]) # Domain Controller - domain_controller = Computer( + domain_controller = Server( hostname="domain_controller", ip_address="192.168.1.10", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) domain_controller.power_on() - network.connect(endpoint_a=domain_controller, endpoint_b=switch_1.switch_ports[1]) + network.connect(endpoint_b=domain_controller.ethernet_port[1], endpoint_a=switch_1.switch_ports[1]) # Web Server - web_server = Computer( + web_server = Server( hostname="web_server", ip_address="192.168.1.12", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) web_server.power_on() - network.connect(endpoint_a=web_server, endpoint_b=switch_1.switch_ports[2]) + network.connect(endpoint_b=web_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[2]) # Database Server - database_server = Computer( + database_server = Server( hostname="database_server", ip_address="192.168.1.14", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) database_server.power_on() - network.connect(endpoint_a=database_server, endpoint_b=switch_1.switch_ports[3]) + network.connect(endpoint_b=database_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[3]) # Backup Server - backup_server = Computer( + backup_server = Server( hostname="backup_server", ip_address="192.168.1.16", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) backup_server.power_on() - network.connect(endpoint_a=backup_server, endpoint_b=switch_1.switch_ports[4]) + network.connect(endpoint_b=backup_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[4]) # Security Suite - security_suite = Computer( + security_suite = Server( hostname="security_suite", ip_address="192.168.1.110", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) security_suite.power_on() - network.connect(endpoint_a=security_suite, endpoint_b=switch_1.switch_ports[7]) - security_suite_external_nic = NIC(ip_address="192.168.10.110", subnet_mask="255.255.255.0") - security_suite.connect_nic(security_suite_external_nic) - network.connect(endpoint_a=security_suite_external_nic, endpoint_b=switch_2.switch_ports[7]) + network.connect(endpoint_b=security_suite.ethernet_port[1], endpoint_a=switch_1.switch_ports[7]) + security_suite.connect_nic(NIC(ip_address="192.168.10.110", subnet_mask="255.255.255.0")) + network.connect(endpoint_b=security_suite.ethernet_port[2], endpoint_a=switch_2.switch_ports[7]) router_1.acl.add_rule( action=ACLAction.PERMIT, diff --git a/src/primaite/simulator/network/transmission/data_link_layer.py b/src/primaite/simulator/network/transmission/data_link_layer.py index 1b7ccf7d..ddd9fad3 100644 --- a/src/primaite/simulator/network/transmission/data_link_layer.py +++ b/src/primaite/simulator/network/transmission/data_link_layer.py @@ -124,6 +124,11 @@ class Frame(BaseModel): if not self.received_timestamp: self.received_timestamp = datetime.now() + def transmission_duration(self) -> int: + """The transmission duration in milliseconds.""" + delta = self.received_timestamp - self.sent_timestamp + return int(delta.microseconds / 1000) + @property def size(self) -> float: # noqa - Keep it as MBits as this is how they're expressed """The size of the Frame in Bytes.""" diff --git a/src/primaite/simulator/system/core/sys_log.py b/src/primaite/simulator/system/core/sys_log.py index 4b858c2e..5a7bbbfe 100644 --- a/src/primaite/simulator/system/core/sys_log.py +++ b/src/primaite/simulator/system/core/sys_log.py @@ -1,6 +1,8 @@ import logging from pathlib import Path +from prettytable import PrettyTable, MARKDOWN + from primaite.simulator import TEMP_SIM_OUTPUT @@ -43,7 +45,7 @@ class SysLog: file_handler = logging.FileHandler(filename=log_path) file_handler.setLevel(logging.DEBUG) - log_format = "%(asctime)s %(levelname)s: %(message)s" + log_format = "%(asctime)s::%(levelname)s::%(message)s" file_handler.setFormatter(logging.Formatter(log_format)) self.logger = logging.getLogger(f"{self.hostname}_sys_log") @@ -52,6 +54,19 @@ class SysLog: self.logger.addFilter(_NotJSONFilter()) + def show(self, last_n: int = 10, markdown: bool = False): + table = PrettyTable(["Timestamp", "Level", "Message"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Sys Log" + if self._get_log_path().exists(): + with open(self._get_log_path()) as file: + lines = file.readlines() + for line in lines[-last_n:]: + table.add_row(line.strip().split("::")) + print(table) + def _get_log_path(self) -> Path: """ Constructs the path for the log file based on the hostname. From 05959e5408cd6c32b83a5cb407164b7e89ef72ca Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 4 Sep 2023 12:14:24 +0100 Subject: [PATCH 15/21] #1800 - Moved the Switch code to a dedicated switch.py module. - Added more switch tests. - Updated ACL tests to use router acl. - Updated more docs. - Moved the Jupyter notebooks to _package_data and fixed up the setup to move all notebooks to ~/primaite/notebooks/example_notebooks. --- CHANGELOG.md | 10 +- MANIFEST.in | 1 + .../simulation_components/network/network.rst | 3 +- .../simulation_components/network/switch.rst | 8 - src/primaite/notebooks/__init__.py | 34 - src/primaite/setup/reset_demo_notebooks.py | 45 +- .../create-simulation_demo.ipynb} | 0 .../network_simulator_demo.ipynb | 688 ++++++++++++++++++ src/primaite/simulator/network/container.py | 23 +- .../simulator/network/hardware/base.py | 115 +-- .../network/hardware/nodes/computer.py | 2 +- .../network/hardware/nodes/router.py | 101 +-- .../network/hardware/nodes/server.py | 3 - .../network/hardware/nodes/switch.py | 121 +++ src/primaite/simulator/network/networks.py | 67 +- src/primaite/simulator/system/core/sys_log.py | 10 +- .../network/test_frame_transmission.py | 41 +- .../network/test_nic_link_connection.py | 5 +- .../network/test_switched_network.py | 25 + .../nodes/{test_router.py => test_acl.py} | 23 +- 20 files changed, 992 insertions(+), 333 deletions(-) delete mode 100644 docs/source/simulation_components/network/switch.rst delete mode 100644 src/primaite/notebooks/__init__.py rename src/primaite/{notebooks/create-simulation.ipynb => simulator/_package_data/create-simulation_demo.ipynb} (100%) create mode 100644 src/primaite/simulator/_package_data/network_simulator_demo.ipynb create mode 100644 src/primaite/simulator/network/hardware/nodes/switch.py create mode 100644 tests/integration_tests/network/test_switched_network.py rename tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/{test_router.py => test_acl.py} (84%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2b495c09..2f2918aa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,12 +7,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] + + ### Added -- Network Hardware - Added base hardware module with NIC, SwitchPort, Node, Switch, and Link. Nodes and Switches have +- Network Hardware - Added base hardware module with NIC, SwitchPort, Node, and Link. Nodes have fundamental services like ARP, ICMP, and PCAP running them by default. - Network Transmission - Modelled OSI Model layers 1 through to 5 with various classes for creating network frames and transmitting them from a Service/Application, down through the layers, over the wire, and back up through the layers to a Service/Application another machine. +- Introduced `Router` and `Switch` classes to manage networking routes more effectively. + - Added `ACLRule` and `RouteTableEntry` classes as part of the `Router`. +- New `.show()` methods in all network component classes to inspect the state in either plain text or markdown formats. +- Added `Computer` and `Server` class to better differentiate types of network nodes. +- Integrated a new Use Case 2 network into the system. +- New unit tests to verify routing between different subnets using `.ping()`. - system - Added the core structure of Application, Services, and Components. Also added a SoftwareManager and SessionManager. - Permission System - each action can define criteria that will be used to permit or deny agent actions. diff --git a/MANIFEST.in b/MANIFEST.in index 51ae4ddf..2ac7b306 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -1,2 +1,3 @@ include src/primaite/setup/_package_data/primaite_config.yaml include src/primaite/config/_package_data/*.yaml +include src/primaite/simulator/_package_data/*.ipynb diff --git a/docs/source/simulation_components/network/network.rst b/docs/source/simulation_components/network/network.rst index e5614980..f4d64b16 100644 --- a/docs/source/simulation_components/network/network.rst +++ b/docs/source/simulation_components/network/network.rst @@ -30,10 +30,11 @@ we'll use the following Network that has a client, server, two switches, and a r .. code-block:: python from primaite.simulator.network.container import Network - from primaite.simulator.network.hardware.base import Switch, NIC + from primaite.simulator.network.hardware.base import NIC from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import Router, ACLAction from primaite.simulator.network.hardware.nodes.server import Server + from primaite.simulator.network.hardware.nodes.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port diff --git a/docs/source/simulation_components/network/switch.rst b/docs/source/simulation_components/network/switch.rst deleted file mode 100644 index 4b3b24bc..00000000 --- a/docs/source/simulation_components/network/switch.rst +++ /dev/null @@ -1,8 +0,0 @@ -.. only:: comment - - © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK - -.. _about: - -Switch -====== \ No newline at end of file diff --git a/src/primaite/notebooks/__init__.py b/src/primaite/notebooks/__init__.py deleted file mode 100644 index bc1dcfcd..00000000 --- a/src/primaite/notebooks/__init__.py +++ /dev/null @@ -1,34 +0,0 @@ -# © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK -"""Contains default jupyter notebooks which demonstrate PrimAITE functionality.""" - -import importlib.util -import os -import subprocess -import sys -from logging import Logger - -from primaite import getLogger, PRIMAITE_PATHS - -_LOGGER: Logger = getLogger(__name__) - - -def start_jupyter_session() -> None: - """ - Starts a new Jupyter notebook session in the app notebooks directory. - - Currently only works on Windows OS. - - .. todo:: Figure out how to get this working for Linux and MacOS too. - """ - if importlib.util.find_spec("jupyter") is not None: - jupyter_cmd = "python3 -m jupyter lab" - if sys.platform == "win32": - jupyter_cmd = "jupyter lab" - - working_dir = os.getcwd() - os.chdir(PRIMAITE_PATHS.user_notebooks_path) - subprocess.Popen(jupyter_cmd) - os.chdir(working_dir) - else: - # Jupyter is not installed - _LOGGER.error("Cannot start jupyter lab as it is not installed") diff --git a/src/primaite/setup/reset_demo_notebooks.py b/src/primaite/setup/reset_demo_notebooks.py index 1f96c90f..a4ee4c4d 100644 --- a/src/primaite/setup/reset_demo_notebooks.py +++ b/src/primaite/setup/reset_demo_notebooks.py @@ -1,35 +1,46 @@ # © Crown-owned copyright 2023, Defence Science and Technology Laboratory UK import filecmp -import os import shutil from logging import Logger from pathlib import Path -import pkg_resources - from primaite import getLogger, PRIMAITE_PATHS _LOGGER: Logger = getLogger(__name__) +def should_copy_file(src: Path, dest: Path, overwrite_existing: bool) -> bool: + """ + Determine if the file should be copied. + + :param src: The source file Path. + :param dest: The destination file Path. + :param overwrite_existing: A bool to toggle replacing existing edited files on or off. + :return: True if file should be copied, otherwise False. + """ + if not dest.is_file(): + return True + + if overwrite_existing and not filecmp.cmp(src, dest): + return True + + return False + + def run(overwrite_existing: bool = True) -> None: """ - Resets the demo jupyter notebooks in the users app notebooks directory. + Resets the demo Jupyter notebooks in the user's app notebooks directory. :param overwrite_existing: A bool to toggle replacing existing edited notebooks on or off. """ - notebooks_package_data_root = pkg_resources.resource_filename("primaite", "notebooks/_package_data") - for subdir, dirs, files in os.walk(notebooks_package_data_root): - for file in files: - fp = os.path.join(subdir, file) - path_split = os.path.relpath(fp, notebooks_package_data_root).split(os.sep) - target_fp = PRIMAITE_PATHS.user_notebooks_path / Path(*path_split) - target_fp.parent.mkdir(exist_ok=True, parents=True) - copy_file = not target_fp.is_file() + primaite_root = Path(__file__).parent.parent + example_notebooks_user_dir = PRIMAITE_PATHS.user_notebooks_path / "example_notebooks" + example_notebooks_user_dir.mkdir(exist_ok=True, parents=True) - if overwrite_existing and not copy_file: - copy_file = (not filecmp.cmp(fp, target_fp)) and (".ipynb_checkpoints" not in str(target_fp)) + for src_fp in primaite_root.glob("**/*.ipynb"): + dst_fp = example_notebooks_user_dir / src_fp.name - if copy_file: - shutil.copy2(fp, target_fp) - _LOGGER.info(f"Reset example notebook: {target_fp}") + if should_copy_file(src_fp, dst_fp, overwrite_existing): + print(dst_fp) + shutil.copy2(src_fp, dst_fp) + _LOGGER.info(f"Reset example notebook: {dst_fp}") diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/simulator/_package_data/create-simulation_demo.ipynb similarity index 100% rename from src/primaite/notebooks/create-simulation.ipynb rename to src/primaite/simulator/_package_data/create-simulation_demo.ipynb diff --git a/src/primaite/simulator/_package_data/network_simulator_demo.ipynb b/src/primaite/simulator/_package_data/network_simulator_demo.ipynb new file mode 100644 index 00000000..252f31fa --- /dev/null +++ b/src/primaite/simulator/_package_data/network_simulator_demo.ipynb @@ -0,0 +1,688 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "id": "03b2013a-b7d1-47ee-b08c-8dab83833720", + "metadata": {}, + "source": [ + "# PrimAITE Router Simulation Demo\n", + "\n", + "This demo uses the ARCD Use Case 2 Network (seen below) to demonstrate the capabilities of the Network simulator in PrimAITE." + ] + }, + { + "cell_type": "raw", + "id": "c8bb5698-e746-4e90-9c2f-efe962acdfa0", + "metadata": {}, + "source": [ + " +------------+\n", + " | domain_ |\n", + " +------------+ controller |\n", + " | | |\n", + " | +------------+\n", + " |\n", + " |\n", + "+------------+ | +------------+\n", + "| | | | |\n", + "| client_1 +---------+ | +---------+ web_server |\n", + "| | | | | | |\n", + "+------------+ | | | +------------+\n", + " +--+---------+ +------------+ +------+--+--+\n", + " | | | | | |\n", + " | switch_2 +------+ router_1 +------+ switch_1 |\n", + " | | | | | |\n", + " +--+------+--+ +------------+ +--+---+--+--+\n", + "+------------+ | | | | | +------------+\n", + "| | | | | | | | database |\n", + "| client_2 +---------+ | | | +---------+ _server |\n", + "| | | | | | |\n", + "+------------+ | | | +------------+\n", + " | +------------+ | |\n", + " | | security | | |\n", + " +---------+ _suite +---------+ | +------------+\n", + " | | | | backup_ |\n", + " +------------+ +------------+ server |\n", + " | |\n", + " +------------+" + ] + }, + { + "cell_type": "markdown", + "id": "415d487c-6457-497d-85d6-99439b3541e7", + "metadata": {}, + "source": [ + "## The Network\n", + "First let's create our network. The network comes 'pre-packaged' with PrimAITE in the `primaite.simulator.network.networks` module.\n", + "\n", + "> ℹ️ You'll see a bunch of logs associated with parts of the Network that aern't an 'electronic' device on the Network and thus don't have a stsrem to log to. Soon these logs are going to be pushed to a Network Logger so we're not clogging up the PrimAITE application logs." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "de57ac8c-5b28-4847-a759-2ceaf5593329", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from primaite.simulator.network.networks import arcd_uc2_network" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a1e2e4df-67c0-4584-ab27-47e2c7c7fcd2", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network = arcd_uc2_network()" + ] + }, + { + "cell_type": "markdown", + "id": "fb052c56-e9ca-4093-9115-d0c440b5ff53", + "metadata": {}, + "source": [ + "Most of the Network components have a `.show()` function that prints a table of information about that object. We can view the Nodes and Links on the Network by calling `network.show()`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "cc199741-ef2e-47f5-b2f0-e20049ccf40f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.show()" + ] + }, + { + "cell_type": "markdown", + "id": "76d2b7e9-280b-4741-a8b3-a84bed219fac", + "metadata": { + "tags": [] + }, + "source": [ + "## Nodes\n", + "\n", + "Now let's inspect some of the nodes. We can directly access a node on the Network by calling .`get_node_by_hostname`. Like Network, a Node, along with some core services like ARP, have a `.show()` method." + ] + }, + { + "cell_type": "markdown", + "id": "84113002-843e-4cab-b899-667b50f25f6b", + "metadata": {}, + "source": [ + "### Router Nodes\n", + "\n", + "First we'll inspect the Router node and some of it's core services." + ] + }, + { + "cell_type": "markdown", + "id": "bf63a178-eee5-4669-bf64-13aea7ecf6cb", + "metadata": {}, + "source": [ + "Calling `router.show()` displays the Ethernet interfaces on the Router. If you need a table in markdown format, pass `markdown=True`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e76d1854-961e-438c-b40f-77fd9c3abe38", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "e000540c-687c-4254-870c-1d814603bdbf", + "metadata": {}, + "source": [ + "Calling `router.arp.show()` displays the Router ARP Cache." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "92de8b42-92d7-4934-9c12-50bf724c9eb2", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").arp.show()" + ] + }, + { + "cell_type": "markdown", + "id": "a9ff7ee8-9482-44de-9039-b684866bdc82", + "metadata": {}, + "source": [ + "Calling `router.acl.show()` displays the Access Control List." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "5922282a-d22b-4e55-9176-f3f3654c849f", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").acl.show()" + ] + }, + { + "cell_type": "markdown", + "id": "71c87884-f793-4c9f-b004-5b0df86cf585", + "metadata": {}, + "source": [ + "Calling `router.router_table.show()` displays the static routes the Router provides." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "327203be-f475-4727-82a1-e992d3b70ed8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").route_table.show()" + ] + }, + { + "cell_type": "markdown", + "id": "eef561a8-3d39-4c8b-bbc8-e8b10b8ed25f", + "metadata": {}, + "source": [ + "Calling `router.sys_log.show()` displays the Router system log. By default, only the last 10 log entries are displayed, this can be changed by passing `last_n=`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "3d0aa004-b10c-445f-aaab-340e0e716c74", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").sys_log.show(last_n=10)" + ] + }, + { + "cell_type": "markdown", + "id": "25630c90-c54e-4b5d-8bf4-ad1b0722e126", + "metadata": {}, + "source": [ + "### Switch Nodes\n", + "\n", + "Next we'll inspect the Switch node and some of it's core services." + ] + }, + { + "cell_type": "markdown", + "id": "4879394d-2981-40de-a229-e19b09a34e6e", + "metadata": {}, + "source": [ + "Calling `switch.show()` displays the Switch orts on the Switch." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e7fd439b-5442-4e9d-9e7d-86dacb77f458", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"switch_1\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "beb8dbd6-7250-4ac9-9fa2-d2a9c0e5fd19", + "metadata": { + "tags": [] + }, + "source": [ + "Calling `switch.arp.show()` displays the Switch ARP Cache." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d06e1310-4a77-4315-a59f-cb1b49ca2352", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"switch_1\").arp.show()" + ] + }, + { + "cell_type": "markdown", + "id": "fda75ac3-8123-4234-8f36-86547891d8df", + "metadata": {}, + "source": [ + "Calling `switch.sys_log.show()` displays the Switch system log. By default, only the last 10 log entries are displayed, this can be changed by passing `last_n=`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a0d984b7-a7c1-4bbd-aa5a-9d3caecb08dc", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"switch_1\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "2f1d99ad-db4f-4baf-8a35-e1d95f269586", + "metadata": {}, + "source": [ + "### Computer/Server Nodes\n", + "\n", + "Finally, we'll inspect a Computer or Server Node and some of its core services." + ] + }, + { + "cell_type": "markdown", + "id": "c9e2251a-1b47-46e5-840f-7fec3e39c5aa", + "metadata": { + "tags": [] + }, + "source": [ + "Calling `computer.show()` displays the NICs on the Computer/Server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "656c37f6-b145-42af-9714-8d2886d0eff8", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"security_suite\").show()" + ] + }, + { + "cell_type": "markdown", + "id": "f1097a49-a3da-4d79-a06d-ae8af452918f", + "metadata": {}, + "source": [ + "Calling `computer.arp.show()` displays the Computer/Server ARP Cache." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "66b267d6-2308-486a-b9aa-cb8d3bcf0753", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"security_suite\").arp.show()" + ] + }, + { + "cell_type": "markdown", + "id": "0d1fcad8-5b1a-4d8b-a49f-aa54a95fcaf0", + "metadata": {}, + "source": [ + "Calling `switch.sys_log.show()` displays the Computer/Server system log. By default, only the last 10 log entries are displayed, this can be changed by passing `last_n=`." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "1b5debe8-ef1b-445d-8fa9-6a45568f21f3", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"security_suite\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "fcfa1773-798c-4ada-9318-c3ad928217da", + "metadata": {}, + "source": [ + "## Basic Network Comms Check\n", + "\n", + "We can perform a good old ping to check that Nodes are able to communicate with each other." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "495b7de4-b6ce-41a6-9114-f74752ab4491", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.show(nodes=False, links=False)" + ] + }, + { + "cell_type": "markdown", + "id": "3e13922a-217f-4f4e-99b6-57a07613cade", + "metadata": {}, + "source": [ + "We'll first ping client_1's default gateway." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a38abb71-994e-49e8-8f51-e9a550e95b99", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.10.1\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "8388e1e9-30e3-4534-8e5a-c6e9144149d2", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").sys_log.show(15)" + ] + }, + { + "cell_type": "markdown", + "id": "02c76d5c-d954-49db-912d-cb9c52f46375", + "metadata": {}, + "source": [ + "Next, we'll ping the interface of the 192.168.1.0/24 Network on the Router (port 1)." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "ff8e976a-c16b-470c-8923-325713a30d6c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.1.1\")" + ] + }, + { + "cell_type": "markdown", + "id": "80280404-a5ab-452f-8a02-771a0d7496b1", + "metadata": {}, + "source": [ + "And finally, we'll ping the web server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "c4163f8d-6a72-410c-9f5c-4f881b7de45e", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "markdown", + "id": "1194c045-ba77-4427-be30-ed7b5b224850", + "metadata": {}, + "source": [ + "To confirm that the ping was received and processed by the web_server, we can view the sys log" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e79a523a-5780-45b6-8798-c434e0e522bd", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"web_server\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "5928f6dd-1006-45e3-99f3-8f311a875faa", + "metadata": {}, + "source": [ + "## Advanced Network Usage\n", + "\n", + "We can now use the Network to perform some more advaced things." + ] + }, + { + "cell_type": "markdown", + "id": "5e023ef3-7d18-4006-96ee-042a06a481fc", + "metadata": {}, + "source": [ + "Let's attempt to prevent client_2 from being able to ping the web server. First, we'll confirm that it can ping the server first..." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "603cf913-e261-49da-a7dd-85e1bb6dec56", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "markdown", + "id": "5cf962a4-20e6-44ae-9748-7fc5267ae111", + "metadata": {}, + "source": [ + "If we look at the client_2 sys log we can see that the four ICMP echo requests were sent and four ICMP each replies were received:" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "e047de00-3de4-4823-b26a-2c8d64c7a663", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "bdc4741d-6e3e-4aec-a69c-c2e9653bd02c", + "metadata": {}, + "source": [ + "Now we'll add an ACL to block ICMP from 192.168.10.22" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6db355ae-b99a-441b-a2c4-4ffe78f46bff", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "from primaite.simulator.network.transmission.network_layer import IPProtocol\n", + "from primaite.simulator.network.transmission.transport_layer import Port\n", + "from primaite.simulator.network.hardware.nodes.router import ACLAction\n", + "network.get_node_by_hostname(\"router_1\").acl.add_rule(\n", + " action=ACLAction.DENY,\n", + " protocol=IPProtocol.ICMP,\n", + " src_ip=\"192.168.10.22\",\n", + " position=1\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a345e000-8842-4827-af96-adc0fbe390fb", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").acl.show()" + ] + }, + { + "cell_type": "markdown", + "id": "3a5bfd9f-04cb-493e-a86c-cd268563a262", + "metadata": {}, + "source": [ + "Now we attempt (and fail) to ping the web server" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "a4f4ff31-590f-40fb-b13d-efaa8c2720b6", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "markdown", + "id": "83e56497-097b-45cb-964e-b15c72547b38", + "metadata": {}, + "source": [ + "We can check that the ping was actually sent by client_2 by viewing the sys log" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "f62b8a4e-fd3b-4059-b108-3d4a0b18f2a0", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_2\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "c7040311-a879-4620-86a0-55d0774156e5", + "metadata": {}, + "source": [ + "We can check the router sys log to see why the traffic was blocked" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "7e53d776-99da-4d2c-a2a7-bd7ce27bff4c", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"router_1\").sys_log.show()" + ] + }, + { + "cell_type": "markdown", + "id": "aba0bc7d-da57-477b-b34a-3688b5aab2c6", + "metadata": {}, + "source": [ + "Now a final check to ensure that client_1 can still ping the web_server." + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d542734b-7582-4af7-8254-bda3de50d091", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").ping(\"192.168.1.12\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "d78e9fe3-02c6-4792-944f-5622e26e0412", + "metadata": { + "tags": [] + }, + "outputs": [], + "source": [ + "network.get_node_by_hostname(\"client_1\").sys_log.show()" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": "Python 3 (ipykernel)", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.11" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index ccb9ce77..239c98a7 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,16 +1,17 @@ -from typing import Any, Dict, Union, Optional, List +from typing import Any, Dict, List, Optional, Union import matplotlib.pyplot as plt import networkx as nx from networkx import MultiGraph -from prettytable import PrettyTable, MARKDOWN +from prettytable import MARKDOWN, PrettyTable from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent -from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort, Switch +from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort from primaite.simulator.network.hardware.nodes.computer import Computer from primaite.simulator.network.hardware.nodes.router import Router from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.hardware.nodes.switch import Switch _LOGGER = getLogger(__name__) @@ -30,7 +31,7 @@ class Network(SimComponent): links: Dict[str, Link] = {} def __init__(self, **kwargs): - """" + """ Initialise the network. Constructs the network and sets up its initial state including @@ -84,14 +85,14 @@ class Network(SimComponent): "Router": self.routers, "Switch": self.switches, "Server": self.servers, - "Computer": self.computers + "Computer": self.computers, } if nodes: table = PrettyTable(["Node", "Type", "Operating State"]) if markdown: table.set_style(MARKDOWN) table.align = "l" - table.title = f"Nodes" + table.title = "Nodes" for node_type, nodes in nodes_type_map.items(): for node in nodes: table.add_row([node.hostname, node_type, node.operating_state.name]) @@ -102,7 +103,7 @@ class Network(SimComponent): if markdown: table.set_style(MARKDOWN) table.align = "l" - table.title = f"IP Addresses" + table.title = "IP Addresses" for nodes in nodes_type_map.values(): for node in nodes: for i, port in node.ethernet_port.items(): @@ -114,7 +115,7 @@ class Network(SimComponent): if markdown: table.set_style(MARKDOWN) table.align = "l" - table.title = f"Links" + table.title = "Links" links = list(self.links.values()) for nodes in nodes_type_map.values(): for node in nodes: @@ -126,7 +127,7 @@ class Network(SimComponent): link.endpoint_b.parent.hostname, link.is_up, link.bandwidth, - link.current_load_percent + link.current_load_percent, ] ) links.remove(link) @@ -207,9 +208,7 @@ class Network(SimComponent): node.parent = None _LOGGER.info(f"Removed node {node.uuid} from network {self.uuid}") - def connect( - self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs - ) -> None: + def connect(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: """ Connect two endpoints on the network by creating a link between their NICs/SwitchPorts. diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 674020ee..1193f3ef 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1,13 +1,12 @@ from __future__ import annotations -import random import re import secrets from enum import Enum from ipaddress import IPv4Address, IPv4Network -from typing import Dict, List, Optional, Tuple, Union +from typing import Any, Dict, List, Optional, Tuple, Union -from prettytable import PrettyTable, MARKDOWN +from prettytable import MARKDOWN, PrettyTable from primaite import getLogger from primaite.exceptions import NetworkError @@ -289,7 +288,7 @@ class SwitchPort(SimComponent): "The speed of the SwitchPort in Mbps. Default is 100 Mbps." mtu: int = 1500 "The Maximum Transmission Unit (MTU) of the SwitchPort in Bytes. Default is 1500 B" - connected_node: Optional[Switch] = None + connected_node: Optional[Node] = None "The Node to which the SwitchPort is connected." connected_link: Optional[Link] = None "The Link to which the SwitchPort is connected." @@ -715,7 +714,7 @@ class ARPCache: arp_packet = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_packet, from_nic) - def __contains__(self, item) -> bool: + def __contains__(self, item: Any) -> bool: return item in self.arp @@ -765,7 +764,7 @@ class ICMP: identifier=frame.icmp.identifier, sequence=frame.icmp.sequence + 1, ) - payload = secrets.token_urlsafe(int(32/1.3)) # Standard ICMP 32 bytes size + payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size frame = Frame( ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload ) @@ -829,7 +828,7 @@ class ICMP: # Data Link Layer ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) icmp_packet = ICMPPacket(identifier=identifier, sequence=sequence) - payload = secrets.token_urlsafe(int(32/1.3)) # Standard ICMP 32 bytes size + payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_packet, payload=payload) nic.send_frame(frame) return sequence, icmp_packet.identifier @@ -1049,7 +1048,8 @@ class Node(SimComponent): f"Ping statistics for {target_ip_address}: " f"Packets: Sent = {pings}, " f"Received = {request_replies}, " - f"Lost = {pings-request_replies} ({(pings-request_replies)/pings*100}% loss)") + f"Lost = {pings-request_replies} ({(pings-request_replies)/pings*100}% loss)" + ) return passed return False @@ -1084,102 +1084,3 @@ class Node(SimComponent): pass elif frame.ip.protocol == IPProtocol.ICMP: self.icmp.process_icmp(frame=frame, from_nic=from_nic) - - -class Switch(Node): - """A class representing a Layer 2 network switch.""" - - num_ports: int = 24 - "The number of ports on the switch." - switch_ports: Dict[int, SwitchPort] = {} - "The SwitchPorts on the switch." - mac_address_table: Dict[str, SwitchPort] = {} - "A MAC address table mapping destination MAC addresses to corresponding SwitchPorts." - - def __init__(self, **kwargs): - super().__init__(**kwargs) - if not self.switch_ports: - self.switch_ports = {i: SwitchPort() for i in range(1, self.num_ports + 1)} - for port_num, port in self.switch_ports.items(): - port.connected_node = self - port.parent = self - port.port_num = port_num - - def show(self, markdown: bool = False): - """Prints a table of the SwitchPorts on the Switch.""" - table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) - if markdown: - table.set_style(MARKDOWN) - table.align = "l" - table.title = f"{self.hostname} Switch Ports" - for port_num, port in self.switch_ports.items(): - table.add_row([port_num, port.mac_address, port.speed, "Enabled" if port.enabled else "Disabled"]) - print(table) - - def describe_state(self) -> Dict: - """ - Produce a dictionary describing the current state of this object. - - Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - - :return: Current state of this object and child objects. - :rtype: Dict - """ - return { - "uuid": self.uuid, - "num_ports": self.num_ports, # redundant? - "ports": {port_num: port.describe_state() for port_num, port in self.switch_ports.items()}, - "mac_address_table": {mac: port for mac, port in self.mac_address_table.items()}, - } - - def _add_mac_table_entry(self, mac_address: str, switch_port: SwitchPort): - mac_table_port = self.mac_address_table.get(mac_address) - if not mac_table_port: - self.mac_address_table[mac_address] = switch_port - self.sys_log.info(f"Added MAC table entry: Port {switch_port.port_num} -> {mac_address}") - else: - if mac_table_port != switch_port: - self.mac_address_table.pop(mac_address) - self.sys_log.info(f"Removed MAC table entry: Port {mac_table_port.port_num} -> {mac_address}") - self._add_mac_table_entry(mac_address, switch_port) - - def forward_frame(self, frame: Frame, incoming_port: SwitchPort): - """ - Forward a frame to the appropriate port based on the destination MAC address. - - :param frame: The Frame to be forwarded. - :param incoming_port: The port number from which the frame was received. - """ - src_mac = frame.ethernet.src_mac_addr - dst_mac = frame.ethernet.dst_mac_addr - self._add_mac_table_entry(src_mac, incoming_port) - - outgoing_port = self.mac_address_table.get(dst_mac) - if outgoing_port or dst_mac != "ff:ff:ff:ff:ff:ff": - outgoing_port.send_frame(frame) - else: - # If the destination MAC is not in the table, flood to all ports except incoming - for port in self.switch_ports.values(): - if port != incoming_port: - port.send_frame(frame) - - def disconnect_link_from_port(self, link: Link, port_number: int): - """ - Disconnect a given link from the specified port number on the switch. - - :param link: The Link object to be disconnected. - :param port_number: The port number on the switch from where the link should be disconnected. - :raise NetworkError: When an invalid port number is provided or the link does not match the connection. - """ - port = self.switch_ports.get(port_number) - if port is None: - msg = f"Invalid port number {port_number} on the switch" - _LOGGER.error(msg) - raise NetworkError(msg) - - if port.connected_link != link: - msg = f"The link does not match the connection at port number {port_number}" - _LOGGER.error(msg) - raise NetworkError(msg) - - port.disconnect_link() diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index 110ad385..2a2e8524 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -1,6 +1,6 @@ from ipaddress import IPv4Address -from primaite.simulator.network.hardware.base import Node, NIC +from primaite.simulator.network.hardware.base import NIC, Node class Computer(Node): diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index b507143b..26ba01ae 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -5,7 +5,7 @@ from enum import Enum from ipaddress import IPv4Address, IPv4Network from typing import Dict, List, Optional, Tuple, Union -from prettytable import PrettyTable, MARKDOWN +from prettytable import MARKDOWN, PrettyTable from primaite.simulator.core import SimComponent from primaite.simulator.network.hardware.base import ARPCache, ICMP, NIC, Node @@ -71,6 +71,7 @@ class AccessControlList(SimComponent): :ivar int max_acl_rules: Maximum number of ACL rules that can be added. Default is 25. :ivar List[Optional[ACLRule]] _acl: A list containing the ACL rules. """ + sys_log: SysLog implicit_action: ACLAction implicit_rule: ACLRule @@ -105,14 +106,14 @@ class AccessControlList(SimComponent): return self._acl def add_rule( - self, - action: ACLAction, - protocol: Optional[IPProtocol] = None, - src_ip: Optional[Union[str, IPv4Address]] = None, - src_port: Optional[Port] = None, - dst_ip: Optional[Union[str, IPv4Address]] = None, - dst_port: Optional[Port] = None, - position: int = 0, + self, + action: ACLAction, + protocol: Optional[IPProtocol] = None, + src_ip: Optional[Union[str, IPv4Address]] = None, + src_port: Optional[Port] = None, + dst_ip: Optional[Union[str, IPv4Address]] = None, + dst_port: Optional[Port] = None, + position: int = 0, ) -> None: """ Add a new ACL rule. @@ -150,12 +151,12 @@ class AccessControlList(SimComponent): raise ValueError(f"Position {position} is out of bounds.") def is_permitted( - self, - protocol: IPProtocol, - src_ip: Union[str, IPv4Address], - src_port: Optional[Port], - dst_ip: Union[str, IPv4Address], - dst_port: Optional[Port], + self, + protocol: IPProtocol, + src_ip: Union[str, IPv4Address], + src_port: Optional[Port], + dst_ip: Union[str, IPv4Address], + dst_port: Optional[Port], ) -> Tuple[bool, Optional[Union[str, ACLRule]]]: """ Check if a packet with the given properties is permitted through the ACL. @@ -177,23 +178,23 @@ class AccessControlList(SimComponent): continue if ( - (rule.src_ip == src_ip or rule.src_ip is None) - and (rule.dst_ip == dst_ip or rule.dst_ip is None) - and (rule.protocol == protocol or rule.protocol is None) - and (rule.src_port == src_port or rule.src_port is None) - and (rule.dst_port == dst_port or rule.dst_port is None) + (rule.src_ip == src_ip or rule.src_ip is None) + and (rule.dst_ip == dst_ip or rule.dst_ip is None) + and (rule.protocol == protocol or rule.protocol is None) + and (rule.src_port == src_port or rule.src_port is None) + and (rule.dst_port == dst_port or rule.dst_port is None) ): return rule.action == ACLAction.PERMIT, rule return self.implicit_action == ACLAction.PERMIT, f"Implicit {self.implicit_action.name}" def get_relevant_rules( - self, - protocol: IPProtocol, - src_ip: Union[str, IPv4Address], - src_port: Port, - dst_ip: Union[str, IPv4Address], - dst_port: Port, + self, + protocol: IPProtocol, + src_ip: Union[str, IPv4Address], + src_port: Port, + dst_ip: Union[str, IPv4Address], + dst_port: Port, ) -> List[ACLRule]: """ Get the list of relevant rules for a packet with given properties. @@ -215,11 +216,11 @@ class AccessControlList(SimComponent): continue if ( - (rule.src_ip == src_ip or rule.src_ip is None) - or (rule.dst_ip == dst_ip or rule.dst_ip is None) - or (rule.protocol == protocol or rule.protocol is None) - or (rule.src_port == src_port or rule.src_port is None) - or (rule.dst_port == dst_port or rule.dst_port is None) + (rule.src_ip == src_ip or rule.src_ip is None) + or (rule.dst_ip == dst_ip or rule.dst_ip is None) + or (rule.protocol == protocol or rule.protocol is None) + or (rule.src_port == src_port or rule.src_port is None) + or (rule.dst_port == dst_port or rule.dst_port is None) ): relevant_rules.append(rule) @@ -326,11 +327,11 @@ class RouteTable(SimComponent): pass def add_route( - self, - address: Union[IPv4Address, str], - subnet_mask: Union[IPv4Address, str], - next_hop: Union[IPv4Address, str], - metric: float = 0.0, + self, + address: Union[IPv4Address, str], + subnet_mask: Union[IPv4Address, str], + next_hop: Union[IPv4Address, str], + metric: float = 0.0, ): """ Add a route to the routing table. @@ -397,6 +398,7 @@ class RouterARPCache(ARPCache): :ivar SysLog sys_log: A system log for logging messages. :ivar Router router: The router to which this ARP cache belongs. """ + def __init__(self, sys_log: SysLog, router: Router): super().__init__(sys_log) self.router: Router = router @@ -416,7 +418,8 @@ class RouterARPCache(ARPCache): if arp_packet.target_ip == nic.ip_address: # reply to the Router specifically self.sys_log.info( - f"Received ARP response for {arp_packet.sender_ip} from {arp_packet.sender_mac_addr} via NIC {from_nic}" + f"Received ARP response for {arp_packet.sender_ip} " + f"from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) self.add_arp_cache_entry( ip_address=arp_packet.sender_ip, @@ -462,6 +465,7 @@ class RouterICMP(ICMP): :param router: The router to which this ICMP handler belongs. :type router: Router """ + router: Router def __init__(self, sys_log: SysLog, arp_cache: ARPCache, router: Router): @@ -492,16 +496,22 @@ class RouterICMP(ICMP): # Network Layer ip_packet = IPPacket(src_ip=nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) # Data Link Layer - ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) + ethernet_header = EthernetHeader( + src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address + ) icmp_reply_packet = ICMPPacket( icmp_type=ICMPType.ECHO_REPLY, icmp_code=0, identifier=frame.icmp.identifier, sequence=frame.icmp.sequence + 1, ) - payload = secrets.token_urlsafe(int(32/1.3)) # Standard ICMP 32 bytes size + payload = secrets.token_urlsafe(int(32 / 1.3)) # Standard ICMP 32 bytes size frame = Frame( - ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload + ethernet=ethernet_header, + ip=ip_packet, + tcp=tcp_header, + icmp=icmp_reply_packet, + payload=payload, ) self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") @@ -540,6 +550,7 @@ class Router(Node): :ivar int num_ports: The number of ports in the router. :ivar dict kwargs: Optional keyword arguments for SysLog, ACL, RouteTable, RouterARPCache, RouterICMP. """ + num_ports: int ethernet_ports: Dict[int, NIC] = {} acl: AccessControlList @@ -588,12 +599,12 @@ class Router(Node): def route_frame(self, frame: Frame, from_nic: NIC, re_attempt: bool = False) -> None: """ - Route a given frame from a source NIC to its destination. + Route a given frame from a source NIC to its destination. - :param frame: The frame to be routed. - :param from_nic: The source network interface. - :param re_attempt: Flag to indicate if the routing is a reattempt. - """ + :param frame: The frame to be routed. + :param from_nic: The source network interface. + :param re_attempt: Flag to indicate if the routing is a reattempt. + """ # Check if src ip is on network of one of the NICs nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip) target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip) diff --git a/src/primaite/simulator/network/hardware/nodes/server.py b/src/primaite/simulator/network/hardware/nodes/server.py index a3e6f2d7..b72cc71c 100644 --- a/src/primaite/simulator/network/hardware/nodes/server.py +++ b/src/primaite/simulator/network/hardware/nodes/server.py @@ -1,6 +1,3 @@ -from ipaddress import IPv4Address - -from primaite.simulator.network.hardware.base import Node, NIC from primaite.simulator.network.hardware.nodes.computer import Computer diff --git a/src/primaite/simulator/network/hardware/nodes/switch.py b/src/primaite/simulator/network/hardware/nodes/switch.py new file mode 100644 index 00000000..b7cc1242 --- /dev/null +++ b/src/primaite/simulator/network/hardware/nodes/switch.py @@ -0,0 +1,121 @@ +from typing import Dict + +from prettytable import MARKDOWN, PrettyTable + +from primaite import getLogger +from primaite.exceptions import NetworkError +from primaite.links.link import Link +from primaite.simulator.network.hardware.base import Node, SwitchPort +from primaite.simulator.network.transmission.data_link_layer import Frame + +_LOGGER = getLogger(__name__) + + +class Switch(Node): + """ + A class representing a Layer 2 network switch. + + :ivar num_ports: The number of ports on the switch. Default is 24. + """ + + num_ports: int = 24 + "The number of ports on the switch." + switch_ports: Dict[int, SwitchPort] = {} + "The SwitchPorts on the switch." + mac_address_table: Dict[str, SwitchPort] = {} + "A MAC address table mapping destination MAC addresses to corresponding SwitchPorts." + + def __init__(self, **kwargs): + super().__init__(**kwargs) + if not self.switch_ports: + self.switch_ports = {i: SwitchPort() for i in range(1, self.num_ports + 1)} + for port_num, port in self.switch_ports.items(): + port.connected_node = self + port.parent = self + port.port_num = port_num + + def show(self, markdown: bool = False): + """ + Prints a table of the SwitchPorts on the Switch. + + :param markdown: If True, outputs the table in markdown format. Default is False. + """ + table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.hostname} Switch Ports" + for port_num, port in self.switch_ports.items(): + table.add_row([port_num, port.mac_address, port.speed, "Enabled" if port.enabled else "Disabled"]) + print(table) + + def describe_state(self) -> Dict: + """ + Produce a dictionary describing the current state of this object. + + :return: Current state of this object and child objects. + """ + return { + "uuid": self.uuid, + "num_ports": self.num_ports, # redundant? + "ports": {port_num: port.describe_state() for port_num, port in self.switch_ports.items()}, + "mac_address_table": {mac: port for mac, port in self.mac_address_table.items()}, + } + + def _add_mac_table_entry(self, mac_address: str, switch_port: SwitchPort): + """ + Private method to add an entry to the MAC address table. + + :param mac_address: MAC address to be added. + :param switch_port: Corresponding SwitchPort object. + """ + mac_table_port = self.mac_address_table.get(mac_address) + if not mac_table_port: + self.mac_address_table[mac_address] = switch_port + self.sys_log.info(f"Added MAC table entry: Port {switch_port.port_num} -> {mac_address}") + else: + if mac_table_port != switch_port: + self.mac_address_table.pop(mac_address) + self.sys_log.info(f"Removed MAC table entry: Port {mac_table_port.port_num} -> {mac_address}") + self._add_mac_table_entry(mac_address, switch_port) + + def forward_frame(self, frame: Frame, incoming_port: SwitchPort): + """ + Forward a frame to the appropriate port based on the destination MAC address. + + :param frame: The Frame to be forwarded. + :param incoming_port: The port number from which the frame was received. + """ + src_mac = frame.ethernet.src_mac_addr + dst_mac = frame.ethernet.dst_mac_addr + self._add_mac_table_entry(src_mac, incoming_port) + + outgoing_port = self.mac_address_table.get(dst_mac) + if outgoing_port or dst_mac != "ff:ff:ff:ff:ff:ff": + outgoing_port.send_frame(frame) + else: + # If the destination MAC is not in the table, flood to all ports except incoming + for port in self.switch_ports.values(): + if port != incoming_port: + port.send_frame(frame) + + def disconnect_link_from_port(self, link: Link, port_number: int): + """ + Disconnect a given link from the specified port number on the switch. + + :param link: The Link object to be disconnected. + :param port_number: The port number on the switch from where the link should be disconnected. + :raise NetworkError: When an invalid port number is provided or the link does not match the connection. + """ + port = self.switch_ports.get(port_number) + if port is None: + msg = f"Invalid port number {port_number} on the switch" + _LOGGER.error(msg) + raise NetworkError(msg) + + if port.connected_link != link: + msg = f"The link does not match the connection at port number {port_number}" + _LOGGER.error(msg) + raise NetworkError(msg) + + port.disconnect_link() diff --git a/src/primaite/simulator/network/networks.py b/src/primaite/simulator/network/networks.py index 28e58ca4..6a50fe3f 100644 --- a/src/primaite/simulator/network/networks.py +++ b/src/primaite/simulator/network/networks.py @@ -1,8 +1,9 @@ from primaite.simulator.network.container import Network -from primaite.simulator.network.hardware.base import Switch, NIC +from primaite.simulator.network.hardware.base import NIC from primaite.simulator.network.hardware.nodes.computer import Computer -from primaite.simulator.network.hardware.nodes.router import Router, ACLAction +from primaite.simulator.network.hardware.nodes.router import ACLAction, Router from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.hardware.nodes.switch import Switch from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port @@ -42,36 +43,21 @@ def client_server_routed() -> Network: # Client 1 client_1 = Computer( - hostname="client_1", - ip_address="192.168.2.2", - subnet_mask="255.255.255.0", - default_gateway="192.168.2.1" + hostname="client_1", ip_address="192.168.2.2", subnet_mask="255.255.255.0", default_gateway="192.168.2.1" ) client_1.power_on() network.connect(endpoint_b=client_1.ethernet_port[1], endpoint_a=switch_2.switch_ports[1]) # Server 1 server_1 = Server( - hostname="server_1", - ip_address="192.168.1.2", - subnet_mask="255.255.255.0", - default_gateway="192.168.1.1" + hostname="server_1", ip_address="192.168.1.2", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) server_1.power_on() network.connect(endpoint_b=server_1.ethernet_port[1], endpoint_a=switch_1.switch_ports[1]) - router_1.acl.add_rule( - action=ACLAction.PERMIT, - src_port=Port.ARP, - dst_port=Port.ARP, - position=22 - ) + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) - router_1.acl.add_rule( - action=ACLAction.PERMIT, - protocol=IPProtocol.ICMP, - position=23 - ) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) return network @@ -135,20 +121,14 @@ def arcd_uc2_network() -> Network: # Client 1 client_1 = Computer( - hostname="client_1", - ip_address="192.168.10.21", - subnet_mask="255.255.255.0", - default_gateway="192.168.10.1" + hostname="client_1", ip_address="192.168.10.21", subnet_mask="255.255.255.0", default_gateway="192.168.10.1" ) client_1.power_on() network.connect(endpoint_b=client_1.ethernet_port[1], endpoint_a=switch_2.switch_ports[1]) # Client 2 client_2 = Computer( - hostname="client_2", - ip_address="192.168.10.22", - subnet_mask="255.255.255.0", - default_gateway="192.168.10.1" + hostname="client_2", ip_address="192.168.10.22", subnet_mask="255.255.255.0", default_gateway="192.168.10.1" ) client_2.power_on() network.connect(endpoint_b=client_2.ethernet_port[1], endpoint_a=switch_2.switch_ports[2]) @@ -158,17 +138,14 @@ def arcd_uc2_network() -> Network: hostname="domain_controller", ip_address="192.168.1.10", subnet_mask="255.255.255.0", - default_gateway="192.168.1.1" + default_gateway="192.168.1.1", ) domain_controller.power_on() network.connect(endpoint_b=domain_controller.ethernet_port[1], endpoint_a=switch_1.switch_ports[1]) # Web Server web_server = Server( - hostname="web_server", - ip_address="192.168.1.12", - subnet_mask="255.255.255.0", - default_gateway="192.168.1.1" + hostname="web_server", ip_address="192.168.1.12", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) web_server.power_on() network.connect(endpoint_b=web_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[2]) @@ -178,17 +155,14 @@ def arcd_uc2_network() -> Network: hostname="database_server", ip_address="192.168.1.14", subnet_mask="255.255.255.0", - default_gateway="192.168.1.1" + default_gateway="192.168.1.1", ) database_server.power_on() network.connect(endpoint_b=database_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[3]) # Backup Server backup_server = Server( - hostname="backup_server", - ip_address="192.168.1.16", - subnet_mask="255.255.255.0", - default_gateway="192.168.1.1" + hostname="backup_server", ip_address="192.168.1.16", subnet_mask="255.255.255.0", default_gateway="192.168.1.1" ) backup_server.power_on() network.connect(endpoint_b=backup_server.ethernet_port[1], endpoint_a=switch_1.switch_ports[4]) @@ -198,24 +172,15 @@ def arcd_uc2_network() -> Network: hostname="security_suite", ip_address="192.168.1.110", subnet_mask="255.255.255.0", - default_gateway="192.168.1.1" + default_gateway="192.168.1.1", ) security_suite.power_on() network.connect(endpoint_b=security_suite.ethernet_port[1], endpoint_a=switch_1.switch_ports[7]) security_suite.connect_nic(NIC(ip_address="192.168.10.110", subnet_mask="255.255.255.0")) network.connect(endpoint_b=security_suite.ethernet_port[2], endpoint_a=switch_2.switch_ports[7]) - router_1.acl.add_rule( - action=ACLAction.PERMIT, - src_port=Port.ARP, - dst_port=Port.ARP, - position=22 - ) + router_1.acl.add_rule(action=ACLAction.PERMIT, src_port=Port.ARP, dst_port=Port.ARP, position=22) - router_1.acl.add_rule( - action=ACLAction.PERMIT, - protocol=IPProtocol.ICMP, - position=23 - ) + router_1.acl.add_rule(action=ACLAction.PERMIT, protocol=IPProtocol.ICMP, position=23) return network diff --git a/src/primaite/simulator/system/core/sys_log.py b/src/primaite/simulator/system/core/sys_log.py index 5a7bbbfe..e07c28aa 100644 --- a/src/primaite/simulator/system/core/sys_log.py +++ b/src/primaite/simulator/system/core/sys_log.py @@ -1,7 +1,7 @@ import logging from pathlib import Path -from prettytable import PrettyTable, MARKDOWN +from prettytable import MARKDOWN, PrettyTable from primaite.simulator import TEMP_SIM_OUTPUT @@ -55,6 +55,14 @@ class SysLog: self.logger.addFilter(_NotJSONFilter()) def show(self, last_n: int = 10, markdown: bool = False): + """ + Print the Node Sys Log as a table. + + Generate and print PrettyTable instance that shows the Nodes Sys Log, with columns Timestamp, Level, + and Massage. + + :param markdown: Use Markdown style in table output. Defaults to False. + """ table = PrettyTable(["Timestamp", "Level", "Message"]) if markdown: table.set_style(MARKDOWN) diff --git a/tests/integration_tests/network/test_frame_transmission.py b/tests/integration_tests/network/test_frame_transmission.py index 34b76060..85717b25 100644 --- a/tests/integration_tests/network/test_frame_transmission.py +++ b/tests/integration_tests/network/test_frame_transmission.py @@ -1,4 +1,4 @@ -from primaite.simulator.network.hardware.base import Link, NIC, Node, Switch +from primaite.simulator.network.hardware.base import Link, NIC, Node def test_node_to_node_ping(): @@ -20,7 +20,6 @@ def test_node_to_node_ping(): def test_multi_nic(): """Tests that Nodes with multiple NICs can ping each other and the data go across the correct links.""" - # TODO Add actual checks. Manual check performed for now. node_a = Node(hostname="node_a") nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") node_a.connect_nic(nic_a) @@ -45,41 +44,3 @@ def test_multi_nic(): node_a.ping("192.168.0.11") assert node_c.ping("10.0.0.12") - - -def test_switched_network(): - """Tests a larges network of Nodes and Switches with one node pinging another.""" - # TODO Add actual checks. Manual check performed for now. - pc_a = Node(hostname="pc_a") - nic_a = NIC(ip_address="192.168.0.10", subnet_mask="255.255.255.0") - pc_a.connect_nic(nic_a) - pc_a.power_on() - - pc_b = Node(hostname="pc_b") - nic_b = NIC(ip_address="192.168.0.11", subnet_mask="255.255.255.0") - pc_b.connect_nic(nic_b) - pc_b.power_on() - - pc_c = Node(hostname="pc_c") - nic_c = NIC(ip_address="192.168.0.12", subnet_mask="255.255.255.0") - pc_c.connect_nic(nic_c) - pc_c.power_on() - - pc_d = Node(hostname="pc_d") - nic_d = NIC(ip_address="192.168.0.13", subnet_mask="255.255.255.0") - pc_d.connect_nic(nic_d) - pc_d.power_on() - - switch_1 = Switch(hostname="switch_1", num_ports=6) - switch_1.power_on() - - switch_2 = Switch(hostname="switch_2", num_ports=6) - switch_2.power_on() - - link_nic_a_switch_1 = Link(endpoint_a=nic_a, endpoint_b=switch_1.switch_ports[1]) - link_nic_b_switch_1 = Link(endpoint_a=nic_b, endpoint_b=switch_1.switch_ports[2]) - link_nic_c_switch_2 = Link(endpoint_a=nic_c, endpoint_b=switch_2.switch_ports[1]) - link_nic_d_switch_2 = Link(endpoint_a=nic_d, endpoint_b=switch_2.switch_ports[2]) - link_switch_1_switch_2 = Link(endpoint_a=switch_1.switch_ports[6], endpoint_b=switch_2.switch_ports[6]) - - assert pc_a.ping("192.168.0.13") diff --git a/tests/integration_tests/network/test_nic_link_connection.py b/tests/integration_tests/network/test_nic_link_connection.py index f051d026..228099c6 100644 --- a/tests/integration_tests/network/test_nic_link_connection.py +++ b/tests/integration_tests/network/test_nic_link_connection.py @@ -6,8 +6,5 @@ from primaite.simulator.network.hardware.base import Link, NIC def test_link_fails_with_same_nic(): """Tests Link creation fails with endpoint_a and endpoint_b are the same NIC.""" with pytest.raises(ValueError): - nic_a = NIC( - ip_address="192.168.1.2", - subnet_mask="255.255.255.0" - ) + nic_a = NIC(ip_address="192.168.1.2", subnet_mask="255.255.255.0") Link(endpoint_a=nic_a, endpoint_b=nic_a) diff --git a/tests/integration_tests/network/test_switched_network.py b/tests/integration_tests/network/test_switched_network.py new file mode 100644 index 00000000..dc7742f4 --- /dev/null +++ b/tests/integration_tests/network/test_switched_network.py @@ -0,0 +1,25 @@ +from primaite.simulator.network.hardware.base import Link +from primaite.simulator.network.hardware.nodes.computer import Computer +from primaite.simulator.network.hardware.nodes.server import Server +from primaite.simulator.network.hardware.nodes.switch import Switch + + +def test_switched_network(): + """Tests a node can ping another node via the switch.""" + client_1 = Computer( + hostname="client_1", ip_address="192.168.1.10", subnet_mask="255.255.255.0", default_gateway="192.168.1.0" + ) + client_1.power_on() + + server_1 = Server( + hostname=" server_1", ip_address="192.168.1.11", subnet_mask="255.255.255.0", default_gateway="192.168.1.11" + ) + server_1.power_on() + + switch_1 = Switch(hostname="switch_1", num_ports=6) + switch_1.power_on() + + Link(endpoint_a=client_1.ethernet_port[1], endpoint_b=switch_1.switch_ports[1]) + Link(endpoint_a=server_1.ethernet_port[1], endpoint_b=switch_1.switch_ports[2]) + + assert client_1.ping("192.168.1.11") diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py similarity index 84% rename from tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py rename to tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py index 48d0fc06..99736421 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_router.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py @@ -1,12 +1,13 @@ from ipaddress import IPv4Address -from primaite.simulator.network.hardware.nodes.router import AccessControlList, ACLAction, ACLRule +from primaite.simulator.network.hardware.nodes.router import ACLAction, Router from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port def test_add_rule(): - acl = AccessControlList() + router = Router("Router") + acl = router.acl acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, @@ -25,7 +26,8 @@ def test_add_rule(): def test_remove_rule(): - acl = AccessControlList() + router = Router("Router") + acl = router.acl acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, @@ -40,7 +42,8 @@ def test_remove_rule(): def test_rules(): - acl = AccessControlList() + router = Router("Router") + acl = router.acl acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, @@ -59,24 +62,27 @@ def test_rules(): dst_port=Port(80), position=2, ) - assert acl.is_permitted( + is_permitted, rule = acl.is_permitted( protocol=IPProtocol.TCP, src_ip=IPv4Address("192.168.1.1"), src_port=Port(8080), dst_ip=IPv4Address("192.168.1.2"), dst_port=Port(80), ) - assert not acl.is_permitted( + assert is_permitted + is_permitted, rule = acl.is_permitted( protocol=IPProtocol.TCP, src_ip=IPv4Address("192.168.1.3"), src_port=Port(8080), dst_ip=IPv4Address("192.168.1.4"), dst_port=Port(80), ) + assert not is_permitted def test_default_rule(): - acl = AccessControlList() + router = Router("Router") + acl = router.acl acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, @@ -95,10 +101,11 @@ def test_default_rule(): dst_port=Port(80), position=2, ) - assert not acl.is_permitted( + is_permitted, rule = acl.is_permitted( protocol=IPProtocol.UDP, src_ip=IPv4Address("192.168.1.5"), src_port=Port(8080), dst_ip=IPv4Address("192.168.1.12"), dst_port=Port(80), ) + assert not is_permitted From d9feb67e02553e703b6a278b2aadde1104352eb5 Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Mon, 4 Sep 2023 11:20:40 +0000 Subject: [PATCH 16/21] Apply suggestions from code review --- src/primaite/simulator/network/hardware/nodes/router.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 26ba01ae..fa1a0858 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -81,12 +81,11 @@ class AccessControlList(SimComponent): def __init__(self, **kwargs) -> None: if not kwargs.get("implicit_action"): kwargs["implicit_action"] = ACLAction.DENY - if not kwargs.get("max_acl_rules"): - kwargs["max_acl_rules"] = 25 + kwargs["implicit_rule"] = ACLRule(action=kwargs["implicit_action"]) - kwargs["_acl"] = [None] * (kwargs["max_acl_rules"] - 1) super().__init__(**kwargs) + self._acl = [None] * (self.max_acl_rules - 1) def describe_state(self) -> Dict: """ @@ -145,7 +144,7 @@ class AccessControlList(SimComponent): :param int position: The position of the rule to be removed. :raises ValueError: When the position is out of bounds. """ - if 0 <= position < self.max_acl_rules: + if 0 <= position < self.max_acl_rules - 1: self._acl[position] = None else: raise ValueError(f"Position {position} is out of bounds.") From 3075d1985b20463e7deddcb02462c5f113aef4ff Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 4 Sep 2023 14:58:34 +0100 Subject: [PATCH 17/21] #1800 - Renamed all ip fields so that they're post-fixed with ip_address --- .../network/transport_to_data_link_layer.rst | 12 +- .../network_simulator_demo.ipynb | 2 +- src/primaite/simulator/core.py | 2 +- .../simulator/network/hardware/base.py | 56 +++---- .../network/hardware/nodes/computer.py | 4 - .../network/hardware/nodes/router.py | 140 +++++++++--------- .../simulator/network/protocols/arp.py | 20 +-- .../network/transmission/data_link_layer.py | 4 +- .../network/transmission/network_layer.py | 20 +-- .../simulator/system/core/session_manager.py | 18 +-- .../_network/_hardware/nodes/test_acl.py | 40 ++--- .../_transmission/test_data_link_layer.py | 14 +- 12 files changed, 166 insertions(+), 166 deletions(-) diff --git a/docs/source/simulation_components/network/transport_to_data_link_layer.rst b/docs/source/simulation_components/network/transport_to_data_link_layer.rst index 4961d337..0220ec45 100644 --- a/docs/source/simulation_components/network/transport_to_data_link_layer.rst +++ b/docs/source/simulation_components/network/transport_to_data_link_layer.rst @@ -64,9 +64,9 @@ Data Link Layer (Layer 2) - **request:** ARP operation. Set to True for a request and False for a reply. - **sender_mac_addr:** Sender's MAC address. - - **sender_ip:** Sender's IP address (IPv4 format). + - **sender_ip_address:** Sender's IP address (IPv4 format). - **target_mac_addr:** Target's MAC address. - - **target_ip:** Target's IP address (IPv4 format). + - **target_ip_address:** Target's IP address (IPv4 format). **EthernetHeader:** Represents the Ethernet layer of a network frame. It includes source and destination MAC addresses. This header is used to identify the physical hardware addresses of devices on a local network. @@ -102,8 +102,8 @@ address of 'aa:bb:cc:dd:ee:ff' to port 8080 on the host 10.0.0.10 which has a NI # Network Layer ip_packet = IPPacket( - src_ip="192.168.0.100", - dst_ip="10.0.0.10", + src_ip_address="192.168.0.100", + dst_ip_address="10.0.0.10", protocol=IPProtocol.TCP ) # Data Link Layer @@ -128,8 +128,8 @@ This produces the following ``Frame`` (displayed in json format) "dst_mac_addr": "11:22:33:44:55:66" }, "ip": { - "src_ip": "192.168.0.100", - "dst_ip": "10.0.0.10", + "src_ip_address": "192.168.0.100", + "dst_ip_address": "10.0.0.10", "protocol": "tcp", "ttl": 64, "precedence": 0 diff --git a/src/primaite/simulator/_package_data/network_simulator_demo.ipynb b/src/primaite/simulator/_package_data/network_simulator_demo.ipynb index 252f31fa..b537f54b 100644 --- a/src/primaite/simulator/_package_data/network_simulator_demo.ipynb +++ b/src/primaite/simulator/_package_data/network_simulator_demo.ipynb @@ -554,7 +554,7 @@ "network.get_node_by_hostname(\"router_1\").acl.add_rule(\n", " action=ACLAction.DENY,\n", " protocol=IPProtocol.ICMP,\n", - " src_ip=\"192.168.10.22\",\n", + " src_ip_address=\"192.168.10.22\",\n", " position=1\n", ")" ] diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index b7dfcf72..3e68ed5f 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import Callable, Dict, List, Optional, Union from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Extra +from pydantic import BaseModel, ConfigDict, Extra, validator from primaite import getLogger diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 1193f3ef..a170506b 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -579,9 +579,13 @@ class ARPCache: """ Add an ARP entry to the cache. + If an entry for the given IP address already exists, the entry is only updated if the `override` parameter is + set to True. + :param ip_address: The IP address to be added to the cache. :param mac_address: The MAC address associated with the IP address. :param nic: The NIC through which the NIC with the IP address is reachable. + :param override: If True, an existing entry for the IP address will be overridden. Default is False. """ for _nic in self.nics.values(): if _nic.ip_address == ip_address: @@ -644,13 +648,13 @@ class ARPCache: # Network Layer ip_packet = IPPacket( - src_ip=nic.ip_address, - dst_ip=target_ip_address, + src_ip_address=nic.ip_address, + dst_ip_address=target_ip_address, ) # Data Link Layer ethernet_header = EthernetHeader(src_mac_addr=nic.mac_address, dst_mac_addr="ff:ff:ff:ff:ff:ff") arp_packet = ARPPacket( - sender_ip=nic.ip_address, sender_mac_addr=nic.mac_address, target_ip=target_ip_address + sender_ip_address=nic.ip_address, sender_mac_addr=nic.mac_address, target_ip_address=target_ip_address ) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) nic.send_frame(frame) @@ -663,14 +667,14 @@ class ARPCache: :param from_nic: The NIC to send the ARP reply from. """ self.sys_log.info( - f"Sending ARP reply from {arp_reply.sender_mac_addr}/{arp_reply.sender_ip} " - f"to {arp_reply.target_ip}/{arp_reply.target_mac_addr} " + f"Sending ARP reply from {arp_reply.sender_mac_addr}/{arp_reply.sender_ip_address} " + f"to {arp_reply.target_ip_address}/{arp_reply.target_mac_addr} " ) tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) ip_packet = IPPacket( - src_ip=arp_reply.sender_ip, - dst_ip=arp_reply.target_ip, + src_ip_address=arp_reply.sender_ip_address, + dst_ip_address=arp_reply.target_ip_address, ) ethernet_header = EthernetHeader(src_mac_addr=arp_reply.sender_mac_addr, dst_mac_addr=arp_reply.target_mac_addr) @@ -691,26 +695,26 @@ class ARPCache: # ARP Reply if not arp_packet.request: self.sys_log.info( - f"Received ARP response for {arp_packet.sender_ip} from {arp_packet.sender_mac_addr} via NIC {from_nic}" + f"Received ARP response for {arp_packet.sender_ip_address} from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) self.add_arp_cache_entry( - ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic + ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic ) return # ARP Request self.sys_log.info( - f"Received ARP request for {arp_packet.target_ip} from " - f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + f"Received ARP request for {arp_packet.target_ip_address} from " + f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip_address} " ) # Unmatched ARP Request - if arp_packet.target_ip != from_nic.ip_address: - self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip}") + if arp_packet.target_ip_address != from_nic.ip_address: + self.sys_log.info(f"Ignoring ARP request for {arp_packet.target_ip_address}") return # Matched ARP request - self.add_arp_cache_entry(ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + self.add_arp_cache_entry(ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic) arp_packet = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_packet, from_nic) @@ -744,18 +748,18 @@ class ICMP: """ if frame.icmp.icmp_type == ICMPType.ECHO_REQUEST: if not is_reattempt: - self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") - target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) + self.sys_log.info(f"Received echo request from {frame.ip.src_ip_address}") + target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip_address) - src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) + src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip_address) if not src_nic: - self.arp.send_arp_request(frame.ip.src_ip) + self.arp.send_arp_request(frame.ip.src_ip_address) self.process_icmp(frame=frame, from_nic=from_nic, is_reattempt=True) return tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer - ip_packet = IPPacket(src_ip=src_nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) + ip_packet = IPPacket(src_ip_address=src_nic.ip_address, dst_ip_address=frame.ip.src_ip_address, protocol=IPProtocol.ICMP) # Data Link Layer ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) icmp_reply_packet = ICMPPacket( @@ -768,14 +772,14 @@ class ICMP: frame = Frame( ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, icmp=icmp_reply_packet, payload=payload ) - self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") src_nic.send_frame(frame) elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: time = frame.transmission_duration() time_str = f"{time}ms" if time > 0 else "<1ms" self.sys_log.info( - f"Reply from {frame.ip.src_ip}: " + f"Reply from {frame.ip.src_ip_address}: " f"bytes={len(frame.payload)}, " f"time={time_str}, " f"TTL={frame.ip.ttl}" @@ -821,8 +825,8 @@ class ICMP: # Network Layer ip_packet = IPPacket( - src_ip=nic.ip_address, - dst_ip=target_ip_address, + src_ip_address=nic.ip_address, + dst_ip_address=target_ip_address, protocol=IPProtocol.ICMP, ) # Data Link Layer @@ -1059,7 +1063,7 @@ class Node(SimComponent): :param frame: The Frame to be sent. """ - nic: NIC = self._get_arp_cache_nic(frame.ip.dst_ip) + nic: NIC = self._get_arp_cache_nic(frame.ip.dst_ip_address) nic.send_frame(frame) def receive_frame(self, frame: Frame, from_nic: NIC): @@ -1073,9 +1077,9 @@ class Node(SimComponent): :param from_nic: The NIC that received the frame. """ if frame.ip: - if frame.ip.src_ip in self.arp: + if frame.ip.src_ip_address in self.arp: self.arp.add_arp_cache_entry( - ip_address=frame.ip.src_ip, mac_address=frame.ethernet.src_mac_addr, nic=from_nic + ip_address=frame.ip.src_ip_address, mac_address=frame.ethernet.src_mac_addr, nic=from_nic ) if frame.ip.protocol == IPProtocol.TCP: if frame.tcp.src_port == Port.ARP: diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index 2a2e8524..a6def4eb 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -36,9 +36,5 @@ class Computer(Node): """ def __init__(self, **kwargs): - for key in {"ip_address", "subnet_mask", "default_gateway"}: - if key in kwargs: - if not isinstance(kwargs[key], IPv4Address): - kwargs[key] = IPv4Address(kwargs[key]) super().__init__(**kwargs) self.connect_nic(NIC(ip_address=kwargs["ip_address"], subnet_mask=kwargs["subnet_mask"])) diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index 26ba01ae..0dd4aaff 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -28,17 +28,17 @@ class ACLRule(SimComponent): :ivar ACLAction action: Action to be performed (Permit/Deny). Default is DENY. :ivar Optional[IPProtocol] protocol: Network protocol. Default is None. - :ivar Optional[IPv4Address] src_ip: Source IP address. Default is None. + :ivar Optional[IPv4Address] src_ip_address: Source IP address. Default is None. :ivar Optional[Port] src_port: Source port number. Default is None. - :ivar Optional[IPv4Address] dst_ip: Destination IP address. Default is None. + :ivar Optional[IPv4Address] dst_ip_address: Destination IP address. Default is None. :ivar Optional[Port] dst_port: Destination port number. Default is None. """ action: ACLAction = ACLAction.DENY protocol: Optional[IPProtocol] = None - src_ip: Optional[IPv4Address] = None + src_ip_address: Optional[IPv4Address] = None src_port: Optional[Port] = None - dst_ip: Optional[IPv4Address] = None + dst_ip_address: Optional[IPv4Address] = None dst_port: Optional[Port] = None def __str__(self) -> str: @@ -109,9 +109,9 @@ class AccessControlList(SimComponent): self, action: ACLAction, protocol: Optional[IPProtocol] = None, - src_ip: Optional[Union[str, IPv4Address]] = None, + src_ip_address: Optional[Union[str, IPv4Address]] = None, src_port: Optional[Port] = None, - dst_ip: Optional[Union[str, IPv4Address]] = None, + dst_ip_address: Optional[Union[str, IPv4Address]] = None, dst_port: Optional[Port] = None, position: int = 0, ) -> None: @@ -120,20 +120,20 @@ class AccessControlList(SimComponent): :param ACLAction action: Action to be performed (Permit/Deny). :param Optional[IPProtocol] protocol: Network protocol. - :param Optional[Union[str, IPv4Address]] src_ip: Source IP address. + :param Optional[Union[str, IPv4Address]] src_ip_address: Source IP address. :param Optional[Port] src_port: Source port number. - :param Optional[Union[str, IPv4Address]] dst_ip: Destination IP address. + :param Optional[Union[str, IPv4Address]] dst_ip_address: Destination IP address. :param Optional[Port] dst_port: Destination port number. :param int position: Position in the ACL list to insert the rule. :raises ValueError: When the position is out of bounds. """ - if isinstance(src_ip, str): - src_ip = IPv4Address(src_ip) - if isinstance(dst_ip, str): - dst_ip = IPv4Address(dst_ip) + if isinstance(src_ip_address, str): + src_ip_address = IPv4Address(src_ip_address) + if isinstance(dst_ip_address, str): + dst_ip_address = IPv4Address(dst_ip_address) if 0 <= position < self.max_acl_rules: self._acl[position] = ACLRule( - action=action, src_ip=src_ip, dst_ip=dst_ip, protocol=protocol, src_port=src_port, dst_port=dst_port + action=action, src_ip_address=src_ip_address, dst_ip_address=dst_ip_address, protocol=protocol, src_port=src_port, dst_port=dst_port ) else: raise ValueError(f"Position {position} is out of bounds.") @@ -153,33 +153,33 @@ class AccessControlList(SimComponent): def is_permitted( self, protocol: IPProtocol, - src_ip: Union[str, IPv4Address], + src_ip_address: Union[str, IPv4Address], src_port: Optional[Port], - dst_ip: Union[str, IPv4Address], + dst_ip_address: Union[str, IPv4Address], dst_port: Optional[Port], ) -> Tuple[bool, Optional[Union[str, ACLRule]]]: """ Check if a packet with the given properties is permitted through the ACL. :param protocol: The protocol of the packet. - :param src_ip: Source IP address of the packet. Accepts string and IPv4Address. + :param src_ip_address: Source IP address of the packet. Accepts string and IPv4Address. :param src_port: Source port of the packet. Optional. - :param dst_ip: Destination IP address of the packet. Accepts string and IPv4Address. + :param dst_ip_address: Destination IP address of the packet. Accepts string and IPv4Address. :param dst_port: Destination port of the packet. Optional. :return: A tuple with a boolean indicating if the packet is permitted and an optional rule or implicit action string. """ - if not isinstance(src_ip, IPv4Address): - src_ip = IPv4Address(src_ip) - if not isinstance(dst_ip, IPv4Address): - dst_ip = IPv4Address(dst_ip) + if not isinstance(src_ip_address, IPv4Address): + src_ip_address = IPv4Address(src_ip_address) + if not isinstance(dst_ip_address, IPv4Address): + dst_ip_address = IPv4Address(dst_ip_address) for rule in self._acl: if not rule: continue if ( - (rule.src_ip == src_ip or rule.src_ip is None) - and (rule.dst_ip == dst_ip or rule.dst_ip is None) + (rule.src_ip_address == src_ip_address or rule.src_ip_address is None) + and (rule.dst_ip_address == dst_ip_address or rule.dst_ip_address is None) and (rule.protocol == protocol or rule.protocol is None) and (rule.src_port == src_port or rule.src_port is None) and (rule.dst_port == dst_port or rule.dst_port is None) @@ -191,33 +191,33 @@ class AccessControlList(SimComponent): def get_relevant_rules( self, protocol: IPProtocol, - src_ip: Union[str, IPv4Address], + src_ip_address: Union[str, IPv4Address], src_port: Port, - dst_ip: Union[str, IPv4Address], + dst_ip_address: Union[str, IPv4Address], dst_port: Port, ) -> List[ACLRule]: """ Get the list of relevant rules for a packet with given properties. :param protocol: The protocol of the packet. - :param src_ip: Source IP address of the packet. Accepts string and IPv4Address. + :param src_ip_address: Source IP address of the packet. Accepts string and IPv4Address. :param src_port: Source port of the packet. - :param dst_ip: Destination IP address of the packet. Accepts string and IPv4Address. + :param dst_ip_address: Destination IP address of the packet. Accepts string and IPv4Address. :param dst_port: Destination port of the packet. :return: A list of relevant ACLRules. """ - if not isinstance(src_ip, IPv4Address): - src_ip = IPv4Address(src_ip) - if not isinstance(dst_ip, IPv4Address): - dst_ip = IPv4Address(dst_ip) + if not isinstance(src_ip_address, IPv4Address): + src_ip_address = IPv4Address(src_ip_address) + if not isinstance(dst_ip_address, IPv4Address): + dst_ip_address = IPv4Address(dst_ip_address) relevant_rules = [] for rule in self._acl: if rule is None: continue if ( - (rule.src_ip == src_ip or rule.src_ip is None) - or (rule.dst_ip == dst_ip or rule.dst_ip is None) + (rule.src_ip_address == src_ip_address or rule.src_ip_address is None) + or (rule.dst_ip_address == dst_ip_address or rule.dst_ip_address is None) or (rule.protocol == protocol or rule.protocol is None) or (rule.src_port == src_port or rule.src_port is None) or (rule.dst_port == dst_port or rule.dst_port is None) @@ -244,9 +244,9 @@ class AccessControlList(SimComponent): index, rule.action.name if rule.action else "ANY", rule.protocol.name if rule.protocol else "ANY", - rule.src_ip if rule.src_ip else "ANY", + rule.src_ip_address if rule.src_ip_address else "ANY", f"{rule.src_port.value} ({rule.src_port.name})" if rule.src_port else "ANY", - rule.dst_ip if rule.dst_ip else "ANY", + rule.dst_ip_address if rule.dst_ip_address else "ANY", f"{rule.dst_port.value} ({rule.dst_port.name})" if rule.dst_port else "ANY", ] ) @@ -260,7 +260,7 @@ class RouteEntry(SimComponent): Attributes: address (IPv4Address): The destination IP address or network address. subnet_mask (IPv4Address): The subnet mask for the network. - next_hop (IPv4Address): The next hop IP address to which packets should be forwarded. + next_hop_ip_address (IPv4Address): The next hop IP address to which packets should be forwarded. metric (int): The cost metric for this route. Default is 0.0. Example: @@ -276,13 +276,13 @@ class RouteEntry(SimComponent): "The destination IP address or network address." subnet_mask: IPv4Address "The subnet mask for the network." - next_hop: IPv4Address + next_hop_ip_address: IPv4Address "The next hop IP address to which packets should be forwarded." metric: float = 0.0 "The cost metric for this route. Default is 0.0." def __init__(self, **kwargs): - for key in {"address", "subnet_mask", "next_hop"}: + for key in {"address", "subnet_mask", "next_hop_ip_address"}: if not isinstance(kwargs[key], IPv4Address): kwargs[key] = IPv4Address(kwargs[key]) super().__init__(**kwargs) @@ -330,7 +330,7 @@ class RouteTable(SimComponent): self, address: Union[IPv4Address, str], subnet_mask: Union[IPv4Address, str], - next_hop: Union[IPv4Address, str], + next_hop_ip_address: Union[IPv4Address, str], metric: float = 0.0, ): """ @@ -338,13 +338,13 @@ class RouteTable(SimComponent): :param address: The destination address of the route. :param subnet_mask: The subnet mask of the route. - :param next_hop: The next hop IP for the route. + :param next_hop_ip_address: The next hop IP for the route. :param metric: The metric of the route, default is 0.0. """ - for key in {address, subnet_mask, next_hop}: + for key in {address, subnet_mask, next_hop_ip_address}: if not isinstance(key, IPv4Address): key = IPv4Address(key) - route = RouteEntry(address=address, subnet_mask=subnet_mask, next_hop=next_hop, metric=metric) + route = RouteEntry(address=address, subnet_mask=subnet_mask, next_hop_ip_address=next_hop_ip_address, metric=metric) self.routes.append(route) def find_best_route(self, destination_ip: Union[str, IPv4Address]) -> Optional[RouteEntry]: @@ -387,7 +387,7 @@ class RouteTable(SimComponent): table.title = f"{self.sys_log.hostname} Route Table" for index, route in enumerate(self.routes): network = IPv4Network(f"{route.address}/{route.subnet_mask}") - table.add_row([index, f"{route.address}/{network.prefixlen}", route.next_hop, route.metric]) + table.add_row([index, f"{route.address}/{network.prefixlen}", route.next_hop_ip_address, route.metric]) print(table) @@ -415,40 +415,40 @@ class RouterARPCache(ARPCache): # ARP Reply if not arp_packet.request: for nic in self.router.nics.values(): - if arp_packet.target_ip == nic.ip_address: + if arp_packet.target_ip_address == nic.ip_address: # reply to the Router specifically self.sys_log.info( - f"Received ARP response for {arp_packet.sender_ip} " + f"Received ARP response for {arp_packet.sender_ip_address} " f"from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) self.add_arp_cache_entry( - ip_address=arp_packet.sender_ip, + ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic, ) return # Reply for a connected requested - nic = self.get_arp_cache_nic(arp_packet.target_ip) + nic = self.get_arp_cache_nic(arp_packet.target_ip_address) if nic: - self.sys_log.info(f"Forwarding arp reply for {arp_packet.target_ip}, from {arp_packet.sender_ip}") + self.sys_log.info(f"Forwarding arp reply for {arp_packet.target_ip_address}, from {arp_packet.sender_ip_address}") arp_packet.sender_mac_addr = nic.mac_address frame.decrement_ttl() nic.send_frame(frame) # ARP Request self.sys_log.info( - f"Received ARP request for {arp_packet.target_ip} from " - f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip} " + f"Received ARP request for {arp_packet.target_ip_address} from " + f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip_address} " ) # Matched ARP request - self.add_arp_cache_entry(ip_address=arp_packet.sender_ip, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + self.add_arp_cache_entry(ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic) arp_packet = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_packet, from_nic) # If the target IP matches one of the router's NICs for nic in self.nics.values(): - if nic.enabled and nic.ip_address == arp_packet.target_ip: + if nic.enabled and nic.ip_address == arp_packet.target_ip_address: arp_reply = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_reply, from_nic) return @@ -484,17 +484,17 @@ class RouterICMP(ICMP): # determine if request is for router interface or whether it needs to be routed for nic in self.router.nics.values(): - if nic.ip_address == frame.ip.dst_ip: + if nic.ip_address == frame.ip.dst_ip_address: if nic.enabled: # reply to the request if not is_reattempt: - self.sys_log.info(f"Received echo request from {frame.ip.src_ip}") - target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip) - src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip) + self.sys_log.info(f"Received echo request from {frame.ip.src_ip_address}") + target_mac_address = self.arp.get_arp_cache_mac_address(frame.ip.src_ip_address) + src_nic = self.arp.get_arp_cache_nic(frame.ip.src_ip_address) tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer - ip_packet = IPPacket(src_ip=nic.ip_address, dst_ip=frame.ip.src_ip, protocol=IPProtocol.ICMP) + ip_packet = IPPacket(src_ip_address=nic.ip_address, dst_ip_address=frame.ip.src_ip_address, protocol=IPProtocol.ICMP) # Data Link Layer ethernet_header = EthernetHeader( src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address @@ -513,7 +513,7 @@ class RouterICMP(ICMP): icmp=icmp_reply_packet, payload=payload, ) - self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip}") + self.sys_log.info(f"Sending echo reply to {frame.ip.dst_ip_address}") src_nic.send_frame(frame) return @@ -523,12 +523,12 @@ class RouterICMP(ICMP): elif frame.icmp.icmp_type == ICMPType.ECHO_REPLY: for nic in self.router.nics.values(): - if nic.ip_address == frame.ip.dst_ip: + if nic.ip_address == frame.ip.dst_ip_address: if nic.enabled: time = frame.transmission_duration() time_str = f"{time}ms" if time > 0 else "<1ms" self.sys_log.info( - f"Reply from {frame.ip.src_ip}: " + f"Reply from {frame.ip.src_ip_address}: " f"bytes={len(frame.payload)}, " f"time={time_str}, " f"TTL={frame.ip.ttl}" @@ -606,22 +606,22 @@ class Router(Node): :param re_attempt: Flag to indicate if the routing is a reattempt. """ # Check if src ip is on network of one of the NICs - nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip) - target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip) + nic = self.arp.get_arp_cache_nic(frame.ip.dst_ip_address) + target_mac = self.arp.get_arp_cache_mac_address(frame.ip.dst_ip_address) if re_attempt and not nic: - self.sys_log.info(f"Destination {frame.ip.dst_ip} is unreachable") + self.sys_log.info(f"Destination {frame.ip.dst_ip_address} is unreachable") return if not nic: - self.arp.send_arp_request(frame.ip.dst_ip) + self.arp.send_arp_request(frame.ip.dst_ip_address) return self.route_frame(frame=frame, from_nic=from_nic, re_attempt=True) if not nic.enabled: # TODO: Add sys_log here return - if frame.ip.dst_ip in nic.ip_network: + if frame.ip.dst_ip_address in nic.ip_network: from_port = self._get_port_of_nic(from_nic) to_port = self._get_port_of_nic(nic) self.sys_log.info(f"Routing frame to internally from port {from_port} to port {to_port}") @@ -643,8 +643,8 @@ class Router(Node): """ route_frame = False protocol = frame.ip.protocol - src_ip = frame.ip.src_ip - dst_ip = frame.ip.dst_ip + src_ip_address = frame.ip.src_ip_address + dst_ip_address = frame.ip.dst_ip_address src_port = None dst_port = None if frame.ip.protocol == IPProtocol.TCP: @@ -656,14 +656,14 @@ class Router(Node): # Check if it's permitted permitted, rule = self.acl.is_permitted( - protocol=protocol, src_ip=src_ip, src_port=src_port, dst_ip=dst_ip, dst_port=dst_port + protocol=protocol, src_ip_address=src_ip_address, src_port=src_port, dst_ip_address=dst_ip_address, dst_port=dst_port ) if not permitted: at_port = self._get_port_of_nic(from_nic) self.sys_log.info(f"Frame blocked at port {at_port} by rule {rule}") return - if not self.arp.get_arp_cache_nic(src_ip): - self.arp.add_arp_cache_entry(src_ip, frame.ethernet.src_mac_addr, from_nic) + if not self.arp.get_arp_cache_nic(src_ip_address): + self.arp.add_arp_cache_entry(src_ip_address, frame.ethernet.src_mac_addr, from_nic) if frame.ip.protocol == IPProtocol.ICMP: self.icmp.process_icmp(frame=frame, from_nic=from_nic) else: diff --git a/src/primaite/simulator/network/protocols/arp.py b/src/primaite/simulator/network/protocols/arp.py index bae14d28..5e38cc66 100644 --- a/src/primaite/simulator/network/protocols/arp.py +++ b/src/primaite/simulator/network/protocols/arp.py @@ -24,21 +24,21 @@ class ARPPacket(BaseModel): :param request: ARP operation. True if a request, False if a reply. :param sender_mac_addr: Sender MAC address. - :param sender_ip: Sender IP address. + :param sender_ip_address: Sender IP address. :param target_mac_addr: Target MAC address. - :param target_ip: Target IP address. + :param target_ip_address: Target IP address. :Example: >>> arp_request = ARPPacket( ... sender_mac_addr="aa:bb:cc:dd:ee:ff", - ... sender_ip=IPv4Address("192.168.0.1"), - ... target_ip=IPv4Address("192.168.0.2") + ... sender_ip_address=IPv4Address("192.168.0.1"), + ... target_ip_address=IPv4Address("192.168.0.2") ... ) >>> arp_response = ARPPacket( ... sender_mac_addr="aa:bb:cc:dd:ee:ff", - ... sender_ip=IPv4Address("192.168.0.1"), - ... target_ip=IPv4Address("192.168.0.2") + ... sender_ip_address=IPv4Address("192.168.0.1"), + ... target_ip_address=IPv4Address("192.168.0.2") ... ) """ @@ -46,11 +46,11 @@ class ARPPacket(BaseModel): "ARP operation. True if a request, False if a reply." sender_mac_addr: str "Sender MAC address." - sender_ip: IPv4Address + sender_ip_address: IPv4Address "Sender IP address." target_mac_addr: Optional[str] = None "Target MAC address." - target_ip: IPv4Address + target_ip_address: IPv4Address "Target IP address." def generate_reply(self, mac_address: str) -> ARPPacket: @@ -62,8 +62,8 @@ class ARPPacket(BaseModel): """ return ARPPacket( request=False, - sender_ip=self.target_ip, + sender_ip_address=self.target_ip_address, sender_mac_addr=mac_address, - target_ip=self.sender_ip, + target_ip_address=self.sender_ip_address, target_mac_addr=self.sender_mac_addr, ) diff --git a/src/primaite/simulator/network/transmission/data_link_layer.py b/src/primaite/simulator/network/transmission/data_link_layer.py index ddd9fad3..b7986622 100644 --- a/src/primaite/simulator/network/transmission/data_link_layer.py +++ b/src/primaite/simulator/network/transmission/data_link_layer.py @@ -52,8 +52,8 @@ class Frame(BaseModel): ... dst_mac_addr='11:22:33:44:55:66' ... ), ... ip=IPPacket( - ... src_ip=IPv4Address('192.168.0.1'), - ... dst_ip=IPv4Address('10.0.0.1'), + ... src_ip_address=IPv4Address('192.168.0.1'), + ... dst_ip_address=IPv4Address('10.0.0.1'), ... ), ... tcp=TCPHeader( ... src_port=8080, diff --git a/src/primaite/simulator/network/transmission/network_layer.py b/src/primaite/simulator/network/transmission/network_layer.py index afd1ecef..fd36fbf8 100644 --- a/src/primaite/simulator/network/transmission/network_layer.py +++ b/src/primaite/simulator/network/transmission/network_layer.py @@ -162,8 +162,8 @@ class IPPacket(BaseModel): """ Represents the IP layer of a network frame. - :param src_ip: Source IP address. - :param dst_ip: Destination IP address. + :param src_ip_address: Source IP address. + :param dst_ip_address: Destination IP address. :param protocol: The IP protocol (default is TCP). :param ttl: Time to Live (TTL) for the packet. :param precedence: Precedence level for Quality of Service (QoS). @@ -172,17 +172,17 @@ class IPPacket(BaseModel): >>> from ipaddress import IPv4Address >>> ip_packet = IPPacket( - ... src_ip=IPv4Address('192.168.0.1'), - ... dst_ip=IPv4Address('10.0.0.1'), + ... src_ip_address=IPv4Address('192.168.0.1'), + ... dst_ip_address=IPv4Address('10.0.0.1'), ... protocol=IPProtocol.TCP, ... ttl=64, ... precedence=Precedence.CRITICAL ... ) """ - src_ip: IPv4Address + src_ip_address: IPv4Address "Source IP address." - dst_ip: IPv4Address + dst_ip_address: IPv4Address "Destination IP address." protocol: IPProtocol = IPProtocol.TCP "IPProtocol." @@ -192,8 +192,8 @@ class IPPacket(BaseModel): "Precedence level for Quality of Service (default is Precedence.ROUTINE)." def __init__(self, **kwargs): - if not isinstance(kwargs["src_ip"], IPv4Address): - kwargs["src_ip"] = IPv4Address(kwargs["src_ip"]) - if not isinstance(kwargs["dst_ip"], IPv4Address): - kwargs["dst_ip"] = IPv4Address(kwargs["dst_ip"]) + if not isinstance(kwargs["src_ip_address"], IPv4Address): + kwargs["src_ip_address"] = IPv4Address(kwargs["src_ip_address"]) + if not isinstance(kwargs["dst_ip_address"], IPv4Address): + kwargs["dst_ip_address"] = IPv4Address(kwargs["dst_ip_address"]) super().__init__(**kwargs) diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index fe7b06b2..705210ff 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -22,16 +22,16 @@ class Session(SimComponent): source and destination IPs and ports. :param protocol: The IP protocol used in the session. - :param src_ip: The source IP address. - :param dst_ip: The destination IP address. + :param src_ip_address: The source IP address. + :param dst_ip_address: The destination IP address. :param src_port: The source port number (optional). :param dst_port: The destination port number (optional). :param connected: A flag indicating whether the session is connected. """ protocol: IPProtocol - src_ip: IPv4Address - dst_ip: IPv4Address + src_ip_address: IPv4Address + dst_ip_address: IPv4Address src_port: Optional[Port] dst_port: Optional[Port] connected: bool = False @@ -46,8 +46,8 @@ class Session(SimComponent): :param session_key: Tuple containing the session details. :return: A Session instance. """ - protocol, src_ip, dst_ip, src_port, dst_port = session_key - return Session(protocol=protocol, src_ip=src_ip, dst_ip=dst_ip, src_port=src_port, dst_port=dst_port) + protocol, src_ip_address, dst_ip_address, src_port, dst_port = session_key + return Session(protocol=protocol, src_ip_address=src_ip_address, dst_ip_address=dst_ip_address, src_port=src_port, dst_port=dst_port) def describe_state(self) -> Dict: """ @@ -108,8 +108,8 @@ class SessionManager: :return: A tuple containing the session key. """ protocol = frame.ip.protocol - src_ip = frame.ip.src_ip - dst_ip = frame.ip.dst_ip + src_ip_address = frame.ip.src_ip_address + dst_ip_address = frame.ip.dst_ip_address if protocol == IPProtocol.TCP: if from_source: src_port = frame.tcp.src_port @@ -127,7 +127,7 @@ class SessionManager: else: src_port = None dst_port = None - return protocol, src_ip, dst_ip, src_port, dst_port + return protocol, src_ip_address, dst_ip_address, src_port, dst_port def receive_payload_from_software_manager(self, payload: Any, session_id: Optional[int] = None): """ diff --git a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py index 99736421..554cba38 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/nodes/test_acl.py @@ -11,17 +11,17 @@ def test_add_rule(): acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.1"), + src_ip_address=IPv4Address("192.168.1.1"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.2"), + dst_ip_address=IPv4Address("192.168.1.2"), dst_port=Port(80), position=1, ) assert acl.acl[1].action == ACLAction.PERMIT assert acl.acl[1].protocol == IPProtocol.TCP - assert acl.acl[1].src_ip == IPv4Address("192.168.1.1") + assert acl.acl[1].src_ip_address == IPv4Address("192.168.1.1") assert acl.acl[1].src_port == Port(8080) - assert acl.acl[1].dst_ip == IPv4Address("192.168.1.2") + assert acl.acl[1].dst_ip_address == IPv4Address("192.168.1.2") assert acl.acl[1].dst_port == Port(80) @@ -31,9 +31,9 @@ def test_remove_rule(): acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.1"), + src_ip_address=IPv4Address("192.168.1.1"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.2"), + dst_ip_address=IPv4Address("192.168.1.2"), dst_port=Port(80), position=1, ) @@ -47,34 +47,34 @@ def test_rules(): acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.1"), + src_ip_address=IPv4Address("192.168.1.1"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.2"), + dst_ip_address=IPv4Address("192.168.1.2"), dst_port=Port(80), position=1, ) acl.add_rule( action=ACLAction.DENY, protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.3"), + src_ip_address=IPv4Address("192.168.1.3"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.4"), + dst_ip_address=IPv4Address("192.168.1.4"), dst_port=Port(80), position=2, ) is_permitted, rule = acl.is_permitted( protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.1"), + src_ip_address=IPv4Address("192.168.1.1"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.2"), + dst_ip_address=IPv4Address("192.168.1.2"), dst_port=Port(80), ) assert is_permitted is_permitted, rule = acl.is_permitted( protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.3"), + src_ip_address=IPv4Address("192.168.1.3"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.4"), + dst_ip_address=IPv4Address("192.168.1.4"), dst_port=Port(80), ) assert not is_permitted @@ -86,26 +86,26 @@ def test_default_rule(): acl.add_rule( action=ACLAction.PERMIT, protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.1"), + src_ip_address=IPv4Address("192.168.1.1"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.2"), + dst_ip_address=IPv4Address("192.168.1.2"), dst_port=Port(80), position=1, ) acl.add_rule( action=ACLAction.DENY, protocol=IPProtocol.TCP, - src_ip=IPv4Address("192.168.1.3"), + src_ip_address=IPv4Address("192.168.1.3"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.4"), + dst_ip_address=IPv4Address("192.168.1.4"), dst_port=Port(80), position=2, ) is_permitted, rule = acl.is_permitted( protocol=IPProtocol.UDP, - src_ip=IPv4Address("192.168.1.5"), + src_ip_address=IPv4Address("192.168.1.5"), src_port=Port(8080), - dst_ip=IPv4Address("192.168.1.12"), + dst_ip_address=IPv4Address("192.168.1.12"), dst_port=Port(80), ) assert not is_permitted diff --git a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py index 8a78d1bc..f9b89de5 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_transmission/test_data_link_layer.py @@ -10,7 +10,7 @@ def test_frame_minimal_instantiation(): """Tests that the minimum frame (TCP SYN) using default values.""" frame = Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20"), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20"), tcp=TCPHeader( src_port=8080, dst_port=80, @@ -38,7 +38,7 @@ def test_frame_creation_fails_tcp_without_header(): with pytest.raises(ValueError): Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.TCP), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.TCP), ) @@ -47,7 +47,7 @@ def test_frame_creation_fails_udp_without_header(): with pytest.raises(ValueError): Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.UDP), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.UDP), ) @@ -56,7 +56,7 @@ def test_frame_creation_fails_tcp_with_udp_header(): with pytest.raises(ValueError): Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.TCP), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.TCP), udp=UDPHeader(src_port=8080, dst_port=80), ) @@ -66,7 +66,7 @@ def test_frame_creation_fails_udp_with_tcp_header(): with pytest.raises(ValueError): Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.UDP), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.UDP), udp=TCPHeader(src_port=8080, dst_port=80), ) @@ -75,7 +75,7 @@ def test_icmp_frame_creation(): """Tests Frame creation for ICMP.""" frame = Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.ICMP), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.ICMP), icmp=ICMPPacket(), ) assert frame @@ -86,5 +86,5 @@ def test_icmp_frame_creation_fails_without_icmp_header(): with pytest.raises(ValueError): Frame( ethernet=EthernetHeader(src_mac_addr="aa:bb:cc:dd:ee:ff", dst_mac_addr="11:22:33:44:55:66"), - ip=IPPacket(src_ip="192.168.0.10", dst_ip="192.168.0.20", protocol=IPProtocol.ICMP), + ip=IPPacket(src_ip_address="192.168.0.10", dst_ip_address="192.168.0.20", protocol=IPProtocol.ICMP), ) From ccad5ba8a319810f1afe7ddd531c929f41b1d5ec Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 4 Sep 2023 16:34:55 +0100 Subject: [PATCH 18/21] #1800 - Ran pre-commit --- src/primaite/simulator/core.py | 2 +- .../simulator/network/hardware/base.py | 15 ++++++--- .../network/hardware/nodes/computer.py | 2 -- .../network/hardware/nodes/router.py | 31 +++++++++++++++---- .../simulator/system/core/session_manager.py | 8 ++++- 5 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 3e68ed5f..b7dfcf72 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import Callable, Dict, List, Optional, Union from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Extra, validator +from pydantic import BaseModel, ConfigDict, Extra from primaite import getLogger diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index a170506b..f2feb961 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -654,7 +654,9 @@ class ARPCache: # Data Link Layer ethernet_header = EthernetHeader(src_mac_addr=nic.mac_address, dst_mac_addr="ff:ff:ff:ff:ff:ff") arp_packet = ARPPacket( - sender_ip_address=nic.ip_address, sender_mac_addr=nic.mac_address, target_ip_address=target_ip_address + sender_ip_address=nic.ip_address, + sender_mac_addr=nic.mac_address, + target_ip_address=target_ip_address, ) frame = Frame(ethernet=ethernet_header, ip=ip_packet, tcp=tcp_header, arp=arp_packet) nic.send_frame(frame) @@ -695,7 +697,8 @@ class ARPCache: # ARP Reply if not arp_packet.request: self.sys_log.info( - f"Received ARP response for {arp_packet.sender_ip_address} from {arp_packet.sender_mac_addr} via NIC {from_nic}" + f"Received ARP response for {arp_packet.sender_ip_address} " + f"from {arp_packet.sender_mac_addr} via NIC {from_nic}" ) self.add_arp_cache_entry( ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic @@ -714,7 +717,9 @@ class ARPCache: return # Matched ARP request - self.add_arp_cache_entry(ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + self.add_arp_cache_entry( + ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic + ) arp_packet = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_packet, from_nic) @@ -759,7 +764,9 @@ class ICMP: tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer - ip_packet = IPPacket(src_ip_address=src_nic.ip_address, dst_ip_address=frame.ip.src_ip_address, protocol=IPProtocol.ICMP) + ip_packet = IPPacket( + src_ip_address=src_nic.ip_address, dst_ip_address=frame.ip.src_ip_address, protocol=IPProtocol.ICMP + ) # Data Link Layer ethernet_header = EthernetHeader(src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address) icmp_reply_packet = ICMPPacket( diff --git a/src/primaite/simulator/network/hardware/nodes/computer.py b/src/primaite/simulator/network/hardware/nodes/computer.py index a6def4eb..5452666b 100644 --- a/src/primaite/simulator/network/hardware/nodes/computer.py +++ b/src/primaite/simulator/network/hardware/nodes/computer.py @@ -1,5 +1,3 @@ -from ipaddress import IPv4Address - from primaite.simulator.network.hardware.base import NIC, Node diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index a8177e86..a34b83e2 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -132,7 +132,12 @@ class AccessControlList(SimComponent): dst_ip_address = IPv4Address(dst_ip_address) if 0 <= position < self.max_acl_rules: self._acl[position] = ACLRule( - action=action, src_ip_address=src_ip_address, dst_ip_address=dst_ip_address, protocol=protocol, src_port=src_port, dst_port=dst_port + action=action, + src_ip_address=src_ip_address, + dst_ip_address=dst_ip_address, + protocol=protocol, + src_port=src_port, + dst_port=dst_port, ) else: raise ValueError(f"Position {position} is out of bounds.") @@ -343,7 +348,9 @@ class RouteTable(SimComponent): for key in {address, subnet_mask, next_hop_ip_address}: if not isinstance(key, IPv4Address): key = IPv4Address(key) - route = RouteEntry(address=address, subnet_mask=subnet_mask, next_hop_ip_address=next_hop_ip_address, metric=metric) + route = RouteEntry( + address=address, subnet_mask=subnet_mask, next_hop_ip_address=next_hop_ip_address, metric=metric + ) self.routes.append(route) def find_best_route(self, destination_ip: Union[str, IPv4Address]) -> Optional[RouteEntry]: @@ -430,7 +437,9 @@ class RouterARPCache(ARPCache): # Reply for a connected requested nic = self.get_arp_cache_nic(arp_packet.target_ip_address) if nic: - self.sys_log.info(f"Forwarding arp reply for {arp_packet.target_ip_address}, from {arp_packet.sender_ip_address}") + self.sys_log.info( + f"Forwarding arp reply for {arp_packet.target_ip_address}, from {arp_packet.sender_ip_address}" + ) arp_packet.sender_mac_addr = nic.mac_address frame.decrement_ttl() nic.send_frame(frame) @@ -441,7 +450,9 @@ class RouterARPCache(ARPCache): f"{arp_packet.sender_mac_addr}/{arp_packet.sender_ip_address} " ) # Matched ARP request - self.add_arp_cache_entry(ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic) + self.add_arp_cache_entry( + ip_address=arp_packet.sender_ip_address, mac_address=arp_packet.sender_mac_addr, nic=from_nic + ) arp_packet = arp_packet.generate_reply(from_nic.mac_address) self.send_arp_reply(arp_packet, from_nic) @@ -493,7 +504,11 @@ class RouterICMP(ICMP): tcp_header = TCPHeader(src_port=Port.ARP, dst_port=Port.ARP) # Network Layer - ip_packet = IPPacket(src_ip_address=nic.ip_address, dst_ip_address=frame.ip.src_ip_address, protocol=IPProtocol.ICMP) + ip_packet = IPPacket( + src_ip_address=nic.ip_address, + dst_ip_address=frame.ip.src_ip_address, + protocol=IPProtocol.ICMP, + ) # Data Link Layer ethernet_header = EthernetHeader( src_mac_addr=src_nic.mac_address, dst_mac_addr=target_mac_address @@ -655,7 +670,11 @@ class Router(Node): # Check if it's permitted permitted, rule = self.acl.is_permitted( - protocol=protocol, src_ip_address=src_ip_address, src_port=src_port, dst_ip_address=dst_ip_address, dst_port=dst_port + protocol=protocol, + src_ip_address=src_ip_address, + src_port=src_port, + dst_ip_address=dst_ip_address, + dst_port=dst_port, ) if not permitted: at_port = self._get_port_of_nic(from_nic) diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 705210ff..7f3d22c5 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -47,7 +47,13 @@ class Session(SimComponent): :return: A Session instance. """ protocol, src_ip_address, dst_ip_address, src_port, dst_port = session_key - return Session(protocol=protocol, src_ip_address=src_ip_address, dst_ip_address=dst_ip_address, src_port=src_port, dst_port=dst_port) + return Session( + protocol=protocol, + src_ip_address=src_ip_address, + dst_ip_address=dst_ip_address, + src_port=src_port, + dst_port=dst_port, + ) def describe_state(self) -> Dict: """ From 596ad20cc6fb814e9327804cd8a3e4b29c72892c Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Mon, 4 Sep 2023 16:44:29 +0100 Subject: [PATCH 19/21] #1800 - Added better logging and error messages to AccessControlList class. Updated usage of extra following pydantic deprecated warning "`pydantic.config.Extra` is deprecated, use literal values instead (e.g. `extra='allow'`)" --- src/primaite/simulator/core.py | 4 ++-- src/primaite/simulator/network/hardware/nodes/router.py | 8 ++++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index b7dfcf72..0501bbb2 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -3,7 +3,7 @@ from abc import ABC, abstractmethod from typing import Callable, Dict, List, Optional, Union from uuid import uuid4 -from pydantic import BaseModel, ConfigDict, Extra +from pydantic import BaseModel, ConfigDict from primaite import getLogger @@ -126,7 +126,7 @@ class ActionManager: class SimComponent(BaseModel): """Extension of pydantic BaseModel with additional methods that must be defined by all classes in the simulator.""" - model_config = ConfigDict(arbitrary_types_allowed=True, extra=Extra.allow) + model_config = ConfigDict(arbitrary_types_allowed=True, extra="allow") """Configure pydantic to allow arbitrary types and to let the instance have attributes not present in model.""" uuid: str diff --git a/src/primaite/simulator/network/hardware/nodes/router.py b/src/primaite/simulator/network/hardware/nodes/router.py index a34b83e2..092680a7 100644 --- a/src/primaite/simulator/network/hardware/nodes/router.py +++ b/src/primaite/simulator/network/hardware/nodes/router.py @@ -131,6 +131,8 @@ class AccessControlList(SimComponent): if isinstance(dst_ip_address, str): dst_ip_address = IPv4Address(dst_ip_address) if 0 <= position < self.max_acl_rules: + if self._acl[position]: + self.sys_log.info(f"Overwriting ACL rule at position {position}") self._acl[position] = ACLRule( action=action, src_ip_address=src_ip_address, @@ -140,7 +142,7 @@ class AccessControlList(SimComponent): dst_port=dst_port, ) else: - raise ValueError(f"Position {position} is out of bounds.") + raise ValueError(f"Cannot add ACL rule, position {position} is out of bounds.") def remove_rule(self, position: int) -> None: """ @@ -150,9 +152,11 @@ class AccessControlList(SimComponent): :raises ValueError: When the position is out of bounds. """ if 0 <= position < self.max_acl_rules - 1: + rule = self._acl[position] # noqa self._acl[position] = None + del rule else: - raise ValueError(f"Position {position} is out of bounds.") + raise ValueError(f"Cannot remove ACL rule, position {position} is out of bounds.") def is_permitted( self, From 289f81826637c4760d7f98dca8e17cff70d60204 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 4 Sep 2023 16:11:47 +0000 Subject: [PATCH 20/21] Apply suggestions from code review --- src/primaite/simulator/file_system/file_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index 1346d3e0..e9385644 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -211,7 +211,7 @@ class FileSystem(SimComponent): if file is not None: return file - def get_folder_by_name(self, folder_name: str) -> Union[FileSystemFolder, None]: + def get_folder_by_name(self, folder_name: str) -> Optional[FileSystemFolder]: """ Returns a the first folder with a matching name. From 0892a976fd47b6f49b1ec7ddd008068ae821c3d8 Mon Sep 17 00:00:00 2001 From: Christopher McCarthy Date: Mon, 4 Sep 2023 18:37:05 +0000 Subject: [PATCH 21/21] Apply suggestions from code review --- src/primaite/simulator/file_system/file_system.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index e9385644..79159e60 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -1,5 +1,5 @@ from random import choice -from typing import Dict, Optional, Union +from typing import Dict, Optional from primaite import getLogger from primaite.simulator.core import SimComponent