From 0f8d31c72c02b16a594184e2d9b792162c21f04a Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 28 Jul 2023 14:41:39 +0100 Subject: [PATCH 01/55] #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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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/55] 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: Wed, 16 Aug 2023 16:45:52 +0100 Subject: [PATCH 10/55] Connect actions of top-level sim components --- src/primaite/simulator/domain/controller.py | 16 +++++++++- src/primaite/simulator/network/container.py | 23 ++++++++++++++ src/primaite/simulator/sim_container.py | 34 +++++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 src/primaite/simulator/network/container.py create mode 100644 src/primaite/simulator/sim_container.py diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 887a065d..4e872531 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -1,7 +1,7 @@ from enum import Enum from typing import Dict, Final, List, Literal, Tuple -from primaite.simulator.core import ActionPermissionValidator, SimComponent +from primaite.simulator.core import Action, ActionManager, ActionPermissionValidator, SimComponent from primaite.simulator.domain.account import Account, AccountType @@ -82,6 +82,20 @@ class DomainController(SimComponent): folders: List[temp_folder] = {} files: List[temp_file] = {} + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.action_manager = ActionManager() + # Action 'account' matches requests like: + # ['account', '', *account_action] + self.action_manager.add_action( + "account", + Action( + func=lambda request, context: self.accounts[request.pop(0)].apply_action(request, context), + validator=GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), + ), + ) + def _register_account(self, account: Account) -> None: """TODO.""" ... diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py new file mode 100644 index 00000000..346a089e --- /dev/null +++ b/src/primaite/simulator/network/container.py @@ -0,0 +1,23 @@ +from typing import Dict + +from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent +from primaite.simulator.network.hardware.base import Link, Node + + +class NetworkContainer(SimComponent): + """TODO.""" + + nodes: Dict[str, Node] = {} + links: Dict[str, Link] = {} + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.action_manager = ActionManager() + self.action_manager.add_action( + "node", + Action( + func=lambda request, context: self.nodes[request.pop(0)].apply_action(request, context), + validator=AllowAllValidator(), + ), + ) diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py new file mode 100644 index 00000000..6989d2b9 --- /dev/null +++ b/src/primaite/simulator/sim_container.py @@ -0,0 +1,34 @@ +from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent +from primaite.simulator.domain.controller import DomainController + + +class __TempNetwork: + """TODO.""" + + pass + + +class SimulationContainer(SimComponent): + """TODO.""" + + network: __TempNetwork + domain: DomainController + + def __init__(self, **kwargs): + super().__init__(**kwargs) + + self.action_manager = ActionManager() + # pass through network actions to the network objects + self.action_manager.add_action( + "network", + Action( + func=lambda request, context: self.network.apply_action(request, context), validator=AllowAllValidator() + ), + ) + # pass through domain actions to the domain object + self.action_manager.add_action( + "domain", + Action( + func=lambda request, context: self.domain.apply_action(request, context), validator=AllowAllValidator() + ), + ) From 6ca53803cd819334f505f7b2019efd7b67951747 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 17 Aug 2023 15:32:12 +0100 Subject: [PATCH 11/55] Describe state --- .../notebooks/create-simulation.ipynb | 140 +++++++++++++ src/primaite/simulator/core.py | 5 +- src/primaite/simulator/domain/account.py | 23 ++- src/primaite/simulator/domain/controller.py | 13 ++ .../simulator/file_system/file_system.py | 11 +- .../simulator/file_system/file_system_file.py | 14 +- .../file_system/file_system_folder.py | 25 ++- .../file_system/file_system_item_abc.py | 18 +- src/primaite/simulator/network/container.py | 18 ++ .../simulator/network/hardware/base.py | 187 +++++++++++------- src/primaite/simulator/sim_container.py | 37 +++- .../system/applications/application.py | 7 +- .../simulator/system/core/session_manager.py | 14 +- .../simulator/system/processes/process.py | 7 +- .../simulator/system/services/service.py | 7 +- src/primaite/simulator/system/software.py | 27 ++- .../_simulator/test_sim_conatiner.py | 16 ++ 17 files changed, 444 insertions(+), 125 deletions(-) create mode 100644 src/primaite/notebooks/create-simulation.ipynb create mode 100644 tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb new file mode 100644 index 00000000..e5fd63b0 --- /dev/null +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -0,0 +1,140 @@ +{ + "cells": [ + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "# Build a simulation using the Python API\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Import the Simulation class" + ] + }, + { + "cell_type": "code", + "execution_count": 1, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.sim_container import Simulation\n" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Create an empty simulation. By default this has a network with no nodes or links, and a domain controller with no accounts.\n", + "\n", + "Let's use the simulation's `describe_state()` method to verify that it is empty." + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", + " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", + " 'nodes': {},\n", + " 'links': {}},\n", + " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_sim = Simulation()\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.base import Node\n" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", + " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", + " 'nodes': {'5f596f4f-4d34-4d1c-9688-9a105e489444': {'uuid': '5f596f4f-4d34-4d1c-9688-9a105e489444',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {},\n", + " 'file_system': {'uuid': 'dc1e7032-7dba-44d5-aedb-5da75ab1eccc',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}}},\n", + " 'links': {}},\n", + " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + ] + }, + "execution_count": 4, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_pc = Node(hostname=\"primaite_pc\",)\n", + "my_sim.network.nodes[my_pc.uuid] = my_pc\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "venv", + "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.12" + }, + "orig_nbformat": 4 + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 7a183588..2c802c0f 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -147,7 +147,10 @@ class SimComponent(BaseModel): object. If there are objects referenced by this object that are owned by something else, it is not included in this output. """ - return {} + state = { + "uuid": self.uuid, + } + return state def apply_action(self, action: List[str], context: Dict = {}) -> None: """ diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index e8595afa..e30b7a27 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -43,8 +43,27 @@ class Account(SimComponent): enabled: bool = True def describe_state(self) -> Dict: - """Describe state for agent observations.""" - return super().describe_state() + """ + 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 + """ + state = super().describe_state() + state.update( + { + "num_logons": self.num_logons, + "num_logoffs": self.num_logoffs, + "num_group_changes": self.num_group_changes, + "username": self.username, + "password": self.password, + "account_type": self.account_type, + "enabled": self.enabled, + } + ) + return state def enable(self): """Set the status to enabled.""" diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 4e872531..f772ab22 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -96,6 +96,19 @@ class DomainController(SimComponent): ), ) + 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 + """ + state = super().describe_state() + state.update({"accounts": {uuid: acct.describe_state() for uuid, acct in self.accounts.items()}}) + return state + def _register_account(self, account: Account) -> None: """TODO.""" ... diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index d42db3e0..a5f603fe 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -18,11 +18,16 @@ class FileSystem(SimComponent): def describe_state(self) -> Dict: """ - Get the current state of the FileSystem as a dict. + Produce a dictionary describing the current state of this object. - :return: A dict containing the current state of the FileSystemFile. + 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 """ - pass + state = super().describe_state() + state.update({"folders": {uuid: folder for uuid, folder in self.folders.items()}}) + return state def get_folders(self) -> Dict: """Returns the list of folders.""" diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index f9fc2e1f..4bb6e585 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -38,8 +38,16 @@ class FileSystemFile(FileSystemItem): def describe_state(self) -> Dict: """ - Get the current state of the FileSystemFile as a dict. + Produce a dictionary describing the current state of this object. - :return: A dict containing the current state of the FileSystemFile. + 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 """ - pass + return { + "uuid": self.uuid, + "name": self.name, + "size": self.size, + "file_type": self.file_type, + } diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index b0705804..463f3854 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -16,6 +16,23 @@ class FileSystemFolder(FileSystemItem): is_quarantined: bool = False """Flag that marks the folder as quarantined if true.""" + 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, + "name": self.name, + "size": self.size, + "files": {uuid: file for uuid, file in self.files.items()}, + "is_quarantined": self.is_quarantined, + } + def get_file_by_id(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" return self.files.get(file_id) @@ -67,11 +84,3 @@ class FileSystemFolder(FileSystemItem): def quarantine_status(self) -> bool: """Returns true if the folder is being quarantined.""" return self.is_quarantined - - def describe_state(self) -> Dict: - """ - Get the current state of the FileSystemFolder as a dict. - - :return: A dict containing the current state of the FileSystemFile. - """ - pass diff --git a/src/primaite/simulator/file_system/file_system_item_abc.py b/src/primaite/simulator/file_system/file_system_item_abc.py index 0594cc35..3b368819 100644 --- a/src/primaite/simulator/file_system/file_system_item_abc.py +++ b/src/primaite/simulator/file_system/file_system_item_abc.py @@ -13,5 +13,19 @@ class FileSystemItem(SimComponent): """The size the item takes up on disk.""" def describe_state(self) -> Dict: - """Returns the state of the FileSystemItem.""" - pass + """ + 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 + """ + state = super().describe_state() + state.update( + { + "name": self.name, + "size": self.size, + } + ) + return state diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 346a089e..463d5f91 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -21,3 +21,21 @@ class NetworkContainer(SimComponent): validator=AllowAllValidator(), ), ) + + 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 + """ + state = super().describe_state() + state.update( + { + "nodes": {uuid: node.describe_state() for uuid, node in self.nodes.items()}, + "links": {uuid: link.describe_state() for uuid, link in self.links.items()}, + } + ) + return state diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index ab5d4943..b731862b 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -125,6 +125,31 @@ class NIC(SimComponent): _LOGGER.error(msg) raise ValueError(msg) + 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 + """ + state = super().describe_state() + state.update( + { + "ip_adress": self.ip_address, + "subnet_mask": self.subnet_mask, + "gateway": self.gateway, + "mac_address": self.mac_address, + "speed": self.speed, + "mtu": self.mtu, + "wake_on_lan": self.wake_on_lan, + "dns_servers": self.dns_servers, + "enabled": self.enabled, + } + ) + return state + @property def ip_network(self) -> IPv4Network: """ @@ -241,23 +266,6 @@ class NIC(SimComponent): return True return False - def describe_state(self) -> Dict: - """ - Get the current state of the NIC as a dict. - - :return: A dict containing the current state of the NIC. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the NIC. - - :param action: The action to be applied. - :type action: str - """ - pass - def __str__(self) -> str: return f"{self.mac_address}/{self.ip_address}" @@ -293,6 +301,25 @@ class SwitchPort(SimComponent): kwargs["mac_address"] = generate_mac_address() super().__init__(**kwargs) + 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 + """ + state = super().describe_state() + state.update( + { + "mac_address": self.mac_address, + "speed": self.speed, + "mtu": self.mtu, + "enabled": self.enabled, + } + ) + def enable(self): """Attempt to enable the SwitchPort.""" if self.enabled: @@ -379,23 +406,6 @@ class SwitchPort(SimComponent): return True return False - def describe_state(self) -> Dict: - """ - Get the current state of the SwitchPort as a dict. - - :return: A dict containing the current state of the SwitchPort. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the SwitchPort. - - :param action: The action to be applied. - :type action: str - """ - pass - def __str__(self) -> str: return f"{self.mac_address}" @@ -435,6 +445,26 @@ class Link(SimComponent): self.endpoint_b.connect_link(self) self.endpoint_up() + 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 + """ + state = super().describe_state() + state.update( + { + "endpoint_a": self.endpoint_a.uuid, + "endpoint_b": self.endpoint_b.uuid, + "bandwidth": self.bandwidth, + "current_load": self.current_load, + } + ) + return state + @property def current_load_percent(self) -> str: """Get the current load formatted as a percentage string.""" @@ -504,23 +534,6 @@ class Link(SimComponent): """ self.current_load = 0 - def describe_state(self) -> Dict: - """ - Get the current state of the Link as a dict. - - :return: A dict containing the current state of the Link. - """ - pass - - def apply_action(self, action: str): - """ - Apply an action to the Link. - - :param action: The action to be applied. - :type action: str - """ - pass - def __str__(self) -> str: return f"{self.endpoint_a}<-->{self.endpoint_b}" @@ -832,6 +845,30 @@ class Node(SimComponent): super().__init__(**kwargs) self.arp.nics = self.nics + 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 + """ + state = super().describe_state() + state.update( + { + "hostname": self.hostname, + "operating_state": self.operating_state.value, + "NICs": {uuid: nic.describe_state() for uuid, nic in self.nics.items()}, + # "switch_ports": {uuid, sp for uuid, sp in self.switch_ports.items()}, + "file_system": self.file_system.describe_state(), + "applications": {uuid: app for uuid, app in self.applications.items()}, + "services": {uuid: svc for uuid, svc in self.services.items()}, + "process": {uuid: proc for uuid, proc in self.processes.items()}, + } + ) + return state + def show(self): """Prints a table of the NICs on the Node..""" from prettytable import PrettyTable @@ -950,14 +987,6 @@ class Node(SimComponent): elif frame.ip.protocol == IPProtocol.ICMP: self.icmp.process_icmp(frame=frame) - def describe_state(self) -> Dict: - """ - Describe the state of the Node. - - :return: A dictionary representing the state of the node. - """ - pass - class Switch(Node): """A class representing a Layer 2 network switch.""" @@ -966,9 +995,17 @@ class Switch(Node): "The number of ports on the switch." switch_ports: Dict[int, SwitchPort] = {} "The SwitchPorts on the switch." - dst_mac_table: Dict[str, SwitchPort] = {} + 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.port_num = port_num + def show(self): """Prints a table of the SwitchPorts on the Switch.""" table = PrettyTable(["Port", "MAC Address", "Speed", "Status"]) @@ -978,25 +1015,29 @@ class Switch(Node): print(table) def describe_state(self) -> Dict: - """TODO.""" - pass + """ + Produce a dictionary describing the current state of this object. - 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.port_num = port_num + 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 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.dst_mac_table.get(mac_address) + mac_table_port = self.mac_address_table.get(mac_address) if not mac_table_port: - self.dst_mac_table[mac_address] = switch_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.dst_mac_table.pop(mac_address) + 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) @@ -1011,7 +1052,7 @@ class Switch(Node): dst_mac = frame.ethernet.dst_mac_addr self._add_mac_table_entry(src_mac, incoming_port) - outgoing_port = self.dst_mac_table.get(dst_mac) + 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: diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 6989d2b9..1a37dc18 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -1,20 +1,23 @@ +from typing import Dict + from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent from primaite.simulator.domain.controller import DomainController +from primaite.simulator.network.container import NetworkContainer -class __TempNetwork: +class Simulation(SimComponent): """TODO.""" - pass - - -class SimulationContainer(SimComponent): - """TODO.""" - - network: __TempNetwork + network: NetworkContainer domain: DomainController def __init__(self, **kwargs): + if not kwargs.get("network"): + kwargs["network"] = NetworkContainer() + + if not kwargs.get("domain"): + kwargs["domain"] = DomainController() + super().__init__(**kwargs) self.action_manager = ActionManager() @@ -32,3 +35,21 @@ class SimulationContainer(SimComponent): func=lambda request, context: self.domain.apply_action(request, context), validator=AllowAllValidator() ), ) + + 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 + """ + state = super().describe_state() + state.update( + { + "network": self.network.describe_state(), + "domain": self.domain.describe_state(), + } + ) + return state diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 36a7bc85..c61afae6 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -36,12 +36,11 @@ class Application(IOSoftware): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 96d6251d..fe7b06b2 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -51,9 +51,12 @@ class Session(SimComponent): def describe_state(self) -> Dict: """ - Describes the current state of the session as a dictionary. + Produce a dictionary describing the current state of this object. - :return: A dictionary containing the current state of the session. + 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 """ pass @@ -77,9 +80,12 @@ class SessionManager: def describe_state(self) -> Dict: """ - Describes the current state of the session manager as a dictionary. + Produce a dictionary describing the current state of this object. - :return: A dictionary containing the current state of the session manager. + 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 """ pass diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py index bbd94345..8e278aa3 100644 --- a/src/primaite/simulator/system/processes/process.py +++ b/src/primaite/simulator/system/processes/process.py @@ -27,12 +27,11 @@ class Process(Software): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 7be5cb78..29a787c5 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -35,12 +35,11 @@ class Service(IOSoftware): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 854e7e2b..5bc08178 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -78,15 +78,25 @@ class Software(SimComponent): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update( + { + "health_state": self.health_state_actual.name, + "health_state_red_view": self.health_state_visible.name, + "criticality": self.criticality.name, + "patching_count": self.patching_count, + "scanning_count": self.scanning_count, + "revealed_to_red": self.revealed_to_red, + } + ) + return state def apply_action(self, action: List[str]) -> None: """ @@ -134,12 +144,11 @@ class IOSoftware(Software): @abstractmethod def describe_state(self) -> Dict: """ - Describes the current state of the software. + Produce a dictionary describing the current state of this object. - The specifics of the software's state, including its health, criticality, - and any other pertinent information, should be implemented in subclasses. + Please see :py:meth:`primaite.simulator.core.SimComponent.describe_state` for a more detailed explanation. - :return: A dictionary containing key-value pairs representing the current state of the software. + :return: Current state of this object and child objects. :rtype: Dict """ pass diff --git a/tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py b/tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py new file mode 100644 index 00000000..4543259d --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/test_sim_conatiner.py @@ -0,0 +1,16 @@ +from primaite.simulator.sim_container import Simulation + + +def test_creating_empty_simulation(): + """Check that no errors occur when trying to setup a simulation without providing parameters""" + empty_sim = Simulation() + + +def test_empty_sim_state(): + """Check that describe_state has the right subcomponents.""" + empty_sim = Simulation() + sim_state = empty_sim.describe_state() + network_state = empty_sim.network.describe_state() + domain_state = empty_sim.domain.describe_state() + assert sim_state["network"] == network_state + assert sim_state["domain"] == domain_state From 01c912c094cfcfdf27609db77cff2a841b64dd17 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 20 Aug 2023 18:38:02 +0100 Subject: [PATCH 12/55] fix type hints and describe state functions --- .../notebooks/create-simulation.ipynb | 400 +++++++++++++++++- src/primaite/simulator/domain/account.py | 2 +- .../simulator/file_system/file_system.py | 4 +- .../simulator/file_system/file_system_file.py | 14 +- .../file_system/file_system_folder.py | 17 +- .../simulator/network/hardware/base.py | 27 +- .../system/applications/application.py | 24 +- .../simulator/system/processes/process.py | 4 +- .../simulator/system/services/service.py | 4 +- src/primaite/simulator/system/software.py | 12 +- 10 files changed, 459 insertions(+), 49 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index e5fd63b0..86a7f6a2 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -40,11 +40,11 @@ { "data": { "text/plain": [ - "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", - " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", " 'nodes': {},\n", " 'links': {}},\n", - " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" ] }, "execution_count": 2, @@ -81,19 +81,28 @@ { "data": { "text/plain": [ - "{'uuid': '8d5cbb1b-aa9b-4f66-8b23-80d47755df69',\n", - " 'network': {'uuid': 'd3569bc4-eeed-40b1-9c3c-0fe80b9bb11c',\n", - " 'nodes': {'5f596f4f-4d34-4d1c-9688-9a105e489444': {'uuid': '5f596f4f-4d34-4d1c-9688-9a105e489444',\n", + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", " 'hostname': 'primaite_pc',\n", " 'operating_state': 0,\n", " 'NICs': {},\n", - " 'file_system': {'uuid': 'dc1e7032-7dba-44d5-aedb-5da75ab1eccc',\n", + " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}},\n", + " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {},\n", + " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", " 'folders': {}},\n", " 'applications': {},\n", " 'services': {},\n", " 'process': {}}},\n", " 'links': {}},\n", - " 'domain': {'uuid': '4d4024ae-5948-4f07-aed9-d2315891cddc', 'accounts': {}}}" + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" ] }, "execution_count": 4, @@ -103,16 +112,387 @@ ], "source": [ "my_pc = Node(hostname=\"primaite_pc\",)\n", + "my_server = Node(hostname=\"google_server\")\n", + "\n", + "# TODO: when there is a proper function for adding nodes, use it instead of manually adding.\n", + "\n", "my_sim.network.nodes[my_pc.uuid] = my_pc\n", + "my_sim.network.nodes[my_server.uuid] = my_server\n", + "\n", + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Connect the nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.network.hardware.base import NIC, Link, Switch\n" + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "metadata": {}, + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2023-08-20 18:34:59,328: NIC c3:08:90:23:29:cb/130.1.1.1 connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", + "2023-08-20 18:34:59,329: SwitchPort 40:4a:3f:2e:ee:2e connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", + "2023-08-20 18:34:59,331: NIC 69:50:cb:76:22:10/130.1.1.2 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n", + "2023-08-20 18:34:59,331: SwitchPort 18:5e:49:ed:21:55 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n" + ] + } + ], + "source": [ + "my_swtich = Switch(hostname=\"switch1\", num_ports=12)\n", + "\n", + "pc_nic = NIC(ip_address=\"130.1.1.1\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", + "my_pc.connect_nic(pc_nic)\n", + "\n", + "\n", + "server_nic = NIC(ip_address=\"130.1.1.2\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", + "my_server.connect_nic(server_nic)\n", + "\n", + "\n", + "pc_to_switch = Link(endpoint_a=pc_nic, endpoint_b=my_swtich.switch_ports[1])\n", + "server_to_swtich = Link(endpoint_a=server_nic, endpoint_b=my_swtich.switch_ports[2])\n", + "\n", + "my_sim.network.links[pc_to_switch.uuid] = pc_to_switch\n", + "my_sim.network.links[server_to_swtich.uuid] = server_to_swtich" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'ip_adress': '130.1.1.1',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': 'c3:08:90:23:29:cb',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}},\n", + " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'ip_adress': '130.1.1.2',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '69:50:cb:76:22:10',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", + " 'folders': {}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}}},\n", + " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", + " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0},\n", + " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", + " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0}}},\n", + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" + ] + }, + "execution_count": 7, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_sim.describe_state()" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add files and folders to nodes\n" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.file_system.file_system_file_type import FileSystemFileType\n", + "from primaite.simulator.file_system.file_system_file import FileSystemFile" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc_downloads_folder = my_pc.file_system.create_folder(\"downloads\")\n", + "my_pc_downloads_folder.add_file(FileSystemFile(name=\"firefox_installer.zip\",file_type=FileSystemFileType.ZIP))" + ] + }, + { + "cell_type": "code", + "execution_count": 10, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "FileSystemFile(uuid='3ecf7223-dafd-4973-8c3b-b85af4e177da', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + ] + }, + "execution_count": 10, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "my_server_folder = my_server.file_system.create_folder(\"static\")\n", + "my_server.file_system.create_file(\"favicon.ico\", file_type=FileSystemFileType.PNG)" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Add applications to nodes" + ] + }, + { + "cell_type": "code", + "execution_count": 11, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.system.applications.application import Application, ApplicationOperatingState\n", + "from primaite.simulator.system.software import SoftwareHealthState, SoftwareCriticality\n", + "from primaite.simulator.network.transmission.transport_layer import Port\n", + "\n", + "class MSPaint(Application):\n", + " def describe_state(self):\n", + " return super().describe_state()" + ] + }, + { + "cell_type": "code", + "execution_count": 12, + "metadata": {}, + "outputs": [], + "source": [ + "mspaint = MSPaint(name = \"mspaint\", health_state_actual=SoftwareHealthState.GOOD, health_state_visible=SoftwareHealthState.GOOD, criticality=SoftwareCriticality.MEDIUM, ports={Port.HTTP}, operating_state=ApplicationOperatingState.RUNNING,execution_control_status='manual')" + ] + }, + { + "cell_type": "code", + "execution_count": 13, + "metadata": {}, + "outputs": [], + "source": [ + "my_pc.applications[mspaint.uuid] = mspaint" + ] + }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "## Create a domain account" + ] + }, + { + "cell_type": "code", + "execution_count": 14, + "metadata": {}, + "outputs": [], + "source": [ + "from primaite.simulator.domain.account import Account, AccountType\n" + ] + }, + { + "cell_type": "code", + "execution_count": 15, + "metadata": {}, + "outputs": [], + "source": [ + "acct = Account(username=\"admin\", password=\"admin12\", account_type=AccountType.USER)\n", + "my_sim.domain.accounts[acct.uuid] = acct" + ] + }, + { + "cell_type": "code", + "execution_count": 16, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", + " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'ip_adress': '130.1.1.1',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': 'c3:08:90:23:29:cb',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", + " 'folders': {'f1fdf2ae-6377-4417-a28a-3edb4058712d': {'uuid': 'f1fdf2ae-6377-4417-a28a-3edb4058712d',\n", + " 'name': 'downloads',\n", + " 'size': 1000.0,\n", + " 'files': {'409b09a3-0d98-4c03-adf2-09190539be45': {'uuid': '409b09a3-0d98-4c03-adf2-09190539be45',\n", + " 'name': 'firefox_installer.zip',\n", + " 'size': 1000.0,\n", + " 'file_type': 'ZIP'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {'cddee888-d1b9-4289-8512-bc0a6672c880': {'uuid': 'cddee888-d1b9-4289-8512-bc0a6672c880',\n", + " 'health_state': 'GOOD',\n", + " 'health_state_red_view': 'GOOD',\n", + " 'criticality': 'MEDIUM',\n", + " 'patching_count': 0,\n", + " 'scanning_count': 0,\n", + " 'revealed_to_red': False,\n", + " 'installing_count': 0,\n", + " 'max_sessions': 1,\n", + " 'tcp': True,\n", + " 'udp': True,\n", + " 'ports': ['HTTP'],\n", + " 'opearting_state': 'RUNNING',\n", + " 'execution_control_status': 'manual',\n", + " 'num_executions': 0,\n", + " 'groups': []}},\n", + " 'services': {},\n", + " 'process': {}},\n", + " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'ip_adress': '130.1.1.2',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '69:50:cb:76:22:10',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", + " 'folders': {'beb5b535-cf6c-431d-94f6-d1097910130d': {'uuid': 'beb5b535-cf6c-431d-94f6-d1097910130d',\n", + " 'name': 'static',\n", + " 'size': 0,\n", + " 'files': {},\n", + " 'is_quarantined': False},\n", + " '6644cd6c-1eca-4fe4-9313-e3481abb895e': {'uuid': '6644cd6c-1eca-4fe4-9313-e3481abb895e',\n", + " 'name': 'root',\n", + " 'size': 40.0,\n", + " 'files': {'3ecf7223-dafd-4973-8c3b-b85af4e177da': {'uuid': '3ecf7223-dafd-4973-8c3b-b85af4e177da',\n", + " 'name': 'favicon.ico',\n", + " 'size': 40.0,\n", + " 'file_type': 'PNG'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}}},\n", + " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", + " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0},\n", + " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", + " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0}}},\n", + " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912',\n", + " 'accounts': {'563a1805-0b32-4ba6-9551-e127e5eb57a8': {'uuid': '563a1805-0b32-4ba6-9551-e127e5eb57a8',\n", + " 'num_logons': 0,\n", + " 'num_logoffs': 0,\n", + " 'num_group_changes': 0,\n", + " 'username': 'admin',\n", + " 'password': 'admin12',\n", + " 'account_type': 'USER',\n", + " 'enabled': True}}}}" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ "my_sim.describe_state()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 17, "metadata": {}, "outputs": [], - "source": [] + "source": [ + "import json" + ] + }, + { + "cell_type": "code", + "execution_count": 22, + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"uuid\": \"f0c06262-1bd9-49ee-81f8-793fb4a5e58e\", \"network\": {\"uuid\": \"455d6a1a-ca23-4135-b326-3ebf75022a45\", \"nodes\": {\"c7c91f06-f128-4891-84a2-83beceea3908\": {\"uuid\": \"c7c91f06-f128-4891-84a2-83beceea3908\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\": {\"uuid\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"c3:08:90:23:29:cb\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"04ffd1e8-dea7-47ad-a088-4856df055ed1\", \"folders\": {\"f1fdf2ae-6377-4417-a28a-3edb4058712d\": {\"uuid\": \"f1fdf2ae-6377-4417-a28a-3edb4058712d\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"409b09a3-0d98-4c03-adf2-09190539be45\": {\"uuid\": \"409b09a3-0d98-4c03-adf2-09190539be45\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"cddee888-d1b9-4289-8512-bc0a6672c880\": {\"uuid\": \"cddee888-d1b9-4289-8512-bc0a6672c880\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"dfcc395a-93ff-4dd5-9684-c80c5885d827\": {\"uuid\": \"dfcc395a-93ff-4dd5-9684-c80c5885d827\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1fd281a0-83ae-49d9-9b40-6aae7b465cab\": {\"uuid\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"69:50:cb:76:22:10\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"aea8f406-05de-4a02-b65f-972aa1fed70e\", \"folders\": {\"beb5b535-cf6c-431d-94f6-d1097910130d\": {\"uuid\": \"beb5b535-cf6c-431d-94f6-d1097910130d\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"6644cd6c-1eca-4fe4-9313-e3481abb895e\": {\"uuid\": \"6644cd6c-1eca-4fe4-9313-e3481abb895e\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"3ecf7223-dafd-4973-8c3b-b85af4e177da\": {\"uuid\": \"3ecf7223-dafd-4973-8c3b-b85af4e177da\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\": {\"uuid\": \"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\", \"endpoint_a\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"endpoint_b\": \"4e6abc87-b4b9-4f95-a9a9-59cac130c6ff\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\": {\"uuid\": \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\", \"endpoint_a\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"endpoint_b\": \"e136553f-333e-4abf-b1f3-ce352ffa4630\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"9da912d5-4c07-4df6-94c2-b3630e178912\", \"accounts\": {\"563a1805-0b32-4ba6-9551-e127e5eb57a8\": {\"uuid\": \"563a1805-0b32-4ba6-9551-e127e5eb57a8\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + ] + }, + "execution_count": 22, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "d = my_sim.describe_state()\n", + "json.dumps(d)" + ] } ], "metadata": { diff --git a/src/primaite/simulator/domain/account.py b/src/primaite/simulator/domain/account.py index e30b7a27..d235c00e 100644 --- a/src/primaite/simulator/domain/account.py +++ b/src/primaite/simulator/domain/account.py @@ -59,7 +59,7 @@ class Account(SimComponent): "num_group_changes": self.num_group_changes, "username": self.username, "password": self.password, - "account_type": self.account_type, + "account_type": self.account_type.name, "enabled": self.enabled, } ) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index a5f603fe..440b7dc5 100644 --- a/src/primaite/simulator/file_system/file_system.py +++ b/src/primaite/simulator/file_system/file_system.py @@ -13,7 +13,7 @@ _LOGGER = getLogger(__name__) class FileSystem(SimComponent): """Class that contains all the simulation File System.""" - folders: Dict = {} + folders: Dict[str, FileSystemFolder] = {} """List containing all the folders in the file system.""" def describe_state(self) -> Dict: @@ -26,7 +26,7 @@ class FileSystem(SimComponent): :rtype: Dict """ state = super().describe_state() - state.update({"folders": {uuid: folder for uuid, folder in self.folders.items()}}) + state.update({"folders": {uuid: folder.describe_state() for uuid, folder in self.folders.items()}}) return state def get_folders(self) -> Dict: diff --git a/src/primaite/simulator/file_system/file_system_file.py b/src/primaite/simulator/file_system/file_system_file.py index 4bb6e585..c25f5973 100644 --- a/src/primaite/simulator/file_system/file_system_file.py +++ b/src/primaite/simulator/file_system/file_system_file.py @@ -45,9 +45,11 @@ class FileSystemFile(FileSystemItem): :return: Current state of this object and child objects. :rtype: Dict """ - return { - "uuid": self.uuid, - "name": self.name, - "size": self.size, - "file_type": self.file_type, - } + state = super().describe_state() + state.update( + { + "uuid": self.uuid, + "file_type": self.file_type.name, + } + ) + return state diff --git a/src/primaite/simulator/file_system/file_system_folder.py b/src/primaite/simulator/file_system/file_system_folder.py index 463f3854..4e461a3a 100644 --- a/src/primaite/simulator/file_system/file_system_folder.py +++ b/src/primaite/simulator/file_system/file_system_folder.py @@ -10,7 +10,7 @@ _LOGGER = getLogger(__name__) class FileSystemFolder(FileSystemItem): """Simulation FileSystemFolder.""" - files: Dict = {} + files: Dict[str, FileSystemFile] = {} """List of files stored in the folder.""" is_quarantined: bool = False @@ -25,13 +25,14 @@ class FileSystemFolder(FileSystemItem): :return: Current state of this object and child objects. :rtype: Dict """ - return { - "uuid": self.uuid, - "name": self.name, - "size": self.size, - "files": {uuid: file for uuid, file in self.files.items()}, - "is_quarantined": self.is_quarantined, - } + state = super().describe_state() + state.update( + { + "files": {uuid: file.describe_state() for uuid, file in self.files.items()}, + "is_quarantined": self.is_quarantined, + } + ) + return state def get_file_by_id(self, file_id: str) -> FileSystemFile: """Return a FileSystemFile with the matching id.""" diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index b731862b..28e7693a 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -11,15 +11,19 @@ from prettytable import PrettyTable from primaite import getLogger from primaite.exceptions import NetworkError from primaite.simulator.core import SimComponent +from primaite.simulator.domain.account import Account from primaite.simulator.file_system.file_system import FileSystem from primaite.simulator.network.protocols.arp import ARPEntry, ARPPacket 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.applications.application import Application from primaite.simulator.system.core.packet_capture import PacketCapture from primaite.simulator.system.core.session_manager import SessionManager from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.core.sys_log import SysLog +from primaite.simulator.system.processes.process import Process +from primaite.simulator.system.services.service import Service _LOGGER = getLogger(__name__) @@ -137,9 +141,9 @@ class NIC(SimComponent): state = super().describe_state() state.update( { - "ip_adress": self.ip_address, - "subnet_mask": self.subnet_mask, - "gateway": self.gateway, + "ip_adress": str(self.ip_address), + "subnet_mask": str(self.subnet_mask), + "gateway": str(self.gateway), "mac_address": self.mac_address, "speed": self.speed, "mtu": self.mtu, @@ -319,6 +323,7 @@ class SwitchPort(SimComponent): "enabled": self.enabled, } ) + return state def enable(self): """Attempt to enable the SwitchPort.""" @@ -802,13 +807,13 @@ class Node(SimComponent): nics: Dict[str, NIC] = {} "The NICs on the node." - accounts: Dict = {} + accounts: Dict[str, Account] = {} "All accounts on the node." - applications: Dict = {} + applications: Dict[str, Application] = {} "All applications on the node." - services: Dict = {} + services: Dict[str, Service] = {} "All services on the node." - processes: Dict = {} + processes: Dict[str, Process] = {} "All processes on the node." file_system: FileSystem "The nodes file system." @@ -862,9 +867,9 @@ class Node(SimComponent): "NICs": {uuid: nic.describe_state() for uuid, nic in self.nics.items()}, # "switch_ports": {uuid, sp for uuid, sp in self.switch_ports.items()}, "file_system": self.file_system.describe_state(), - "applications": {uuid: app for uuid, app in self.applications.items()}, - "services": {uuid: svc for uuid, svc in self.services.items()}, - "process": {uuid: proc for uuid, proc in self.processes.items()}, + "applications": {uuid: app.describe_state() for uuid, app in self.applications.items()}, + "services": {uuid: svc.describe_state() for uuid, svc in self.services.items()}, + "process": {uuid: proc.describe_state() for uuid, proc in self.processes.items()}, } ) return state @@ -1026,7 +1031,7 @@ class Switch(Node): return { "uuid": self.uuid, "num_ports": self.num_ports, # redundant? - "ports": {port_num: port for port_num, port in self.switch_ports.items()}, + "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()}, } diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index c61afae6..37748560 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -8,13 +8,12 @@ from primaite.simulator.system.software import IOSoftware class ApplicationOperatingState(Enum): """Enumeration of Application Operating States.""" - -RUNNING = 1 -"The application is running." -CLOSED = 2 -"The application is closed or not running." -INSTALLING = 3 -"The application is being installed or updated." + RUNNING = 1 + "The application is running." + CLOSED = 2 + "The application is closed or not running." + INSTALLING = 3 + "The application is being installed or updated." class Application(IOSoftware): @@ -43,7 +42,16 @@ class Application(IOSoftware): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update( + { + "opearting_state": self.operating_state.name, + "execution_control_status": self.execution_control_status, + "num_executions": self.num_executions, + "groups": list(self.groups), + } + ) + return state def apply_action(self, action: List[str]) -> None: """ diff --git a/src/primaite/simulator/system/processes/process.py b/src/primaite/simulator/system/processes/process.py index 8e278aa3..c4e94845 100644 --- a/src/primaite/simulator/system/processes/process.py +++ b/src/primaite/simulator/system/processes/process.py @@ -34,4 +34,6 @@ class Process(Software): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update({"operating_state": self.operating_state.name}) + return state diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 29a787c5..eafff3f0 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -42,7 +42,9 @@ class Service(IOSoftware): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update({"operating_state": self.operating_state.name}) + return state def apply_action(self, action: List[str]) -> None: """ diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 5bc08178..a2acd9fb 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -151,7 +151,17 @@ class IOSoftware(Software): :return: Current state of this object and child objects. :rtype: Dict """ - pass + state = super().describe_state() + state.update( + { + "installing_count": self.installing_count, + "max_sessions": self.max_sessions, + "tcp": self.tcp, + "udp": self.udp, + "ports": [port.name for port in self.ports], # TODO: not sure if this should be port.name or port.value + } + ) + return state def send(self, payload: Any, session_id: str, **kwargs) -> bool: """ From 3911010777ad09451f8da4b2ac1a72b947f0ff61 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 20 Aug 2023 18:42:58 +0100 Subject: [PATCH 13/55] update notebook --- src/primaite/notebooks/create-simulation.ipynb | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index 86a7f6a2..a3e2d92c 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -464,6 +464,13 @@ "my_sim.describe_state()" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the state dictionary contains no non-serialisable objects." + ] + }, { "cell_type": "code", "execution_count": 17, From 7c16a9cdde818093c518844868aecdbb4c38f2e1 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Sun, 20 Aug 2023 18:43:21 +0100 Subject: [PATCH 14/55] Update notebook --- .../notebooks/create-simulation.ipynb | 230 +++++------------- 1 file changed, 61 insertions(+), 169 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index a3e2d92c..b0a140a1 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -4,7 +4,9 @@ "cell_type": "markdown", "metadata": {}, "source": [ - "# Build a simulation using the Python API\n" + "# Build a simulation using the Python API\n", + "\n", + "Currently, this notbook manipulates the simulation by directly placing objects inside of the attributes of the network and domain. It should be refactored when proper methods exist for adding these objects.\n" ] }, { @@ -40,11 +42,11 @@ { "data": { "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", + "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", + " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", " 'nodes': {},\n", " 'links': {}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" + " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a', 'accounts': {}}}" ] }, "execution_count": 2, @@ -77,39 +79,7 @@ "cell_type": "code", "execution_count": 4, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", - " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", - " 'hostname': 'primaite_pc',\n", - " 'operating_state': 0,\n", - " 'NICs': {},\n", - " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}},\n", - " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", - " 'hostname': 'google_server',\n", - " 'operating_state': 0,\n", - " 'NICs': {},\n", - " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}}},\n", - " 'links': {}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" - ] - }, - "execution_count": 4, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_pc = Node(hostname=\"primaite_pc\",)\n", "my_server = Node(hostname=\"google_server\")\n", @@ -117,9 +87,7 @@ "# TODO: when there is a proper function for adding nodes, use it instead of manually adding.\n", "\n", "my_sim.network.nodes[my_pc.uuid] = my_pc\n", - "my_sim.network.nodes[my_server.uuid] = my_server\n", - "\n", - "my_sim.describe_state()" + "my_sim.network.nodes[my_server.uuid] = my_server\n" ] }, { @@ -147,10 +115,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "2023-08-20 18:34:59,328: NIC c3:08:90:23:29:cb/130.1.1.1 connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", - "2023-08-20 18:34:59,329: SwitchPort 40:4a:3f:2e:ee:2e connected to Link c3:08:90:23:29:cb/130.1.1.1<-->40:4a:3f:2e:ee:2e\n", - "2023-08-20 18:34:59,331: NIC 69:50:cb:76:22:10/130.1.1.2 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n", - "2023-08-20 18:34:59,331: SwitchPort 18:5e:49:ed:21:55 connected to Link 69:50:cb:76:22:10/130.1.1.2<-->18:5e:49:ed:21:55\n" + "2023-08-20 18:42:51,310: NIC 5c:b6:26:c0:86:61/130.1.1.1 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", + "2023-08-20 18:42:51,311: SwitchPort 01:ef:b1:a3:24:72 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", + "2023-08-20 18:42:51,314: NIC f6:de:1e:63:8e:7f/130.1.1.2 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n", + "2023-08-20 18:42:51,315: SwitchPort 30:9e:c8:d4:5d:f3 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n" ] } ], @@ -172,74 +140,6 @@ "my_sim.network.links[server_to_swtich.uuid] = server_to_swtich" ] }, - { - "cell_type": "code", - "execution_count": 7, - "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", - " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", - " 'hostname': 'primaite_pc',\n", - " 'operating_state': 0,\n", - " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", - " 'ip_adress': '130.1.1.1',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': 'c3:08:90:23:29:cb',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}},\n", - " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", - " 'hostname': 'google_server',\n", - " 'operating_state': 0,\n", - " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", - " 'ip_adress': '130.1.1.2',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': '69:50:cb:76:22:10',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", - " 'folders': {}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}}},\n", - " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", - " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", - " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0},\n", - " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", - " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", - " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912', 'accounts': {}}}" - ] - }, - "execution_count": 7, - "metadata": {}, - "output_type": "execute_result" - } - ], - "source": [ - "my_sim.describe_state()" - ] - }, { "cell_type": "markdown", "metadata": {}, @@ -249,7 +149,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -259,7 +159,7 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -269,16 +169,16 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": 9, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "FileSystemFile(uuid='3ecf7223-dafd-4973-8c3b-b85af4e177da', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + "FileSystemFile(uuid='253e4606-0f6d-4e57-8db0-6fa7e331ecea', name='favicon.ico', size=40.0, file_type=, action_manager=None)" ] }, - "execution_count": 10, + "execution_count": 9, "metadata": {}, "output_type": "execute_result" } @@ -297,7 +197,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -305,6 +205,7 @@ "from primaite.simulator.system.software import SoftwareHealthState, SoftwareCriticality\n", "from primaite.simulator.network.transmission.transport_layer import Port\n", "\n", + "# no applications exist yet so we will create our own.\n", "class MSPaint(Application):\n", " def describe_state(self):\n", " return super().describe_state()" @@ -312,7 +213,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -321,7 +222,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -337,7 +238,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -346,7 +247,7 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -354,39 +255,46 @@ "my_sim.domain.accounts[acct.uuid] = acct" ] }, + { + "cell_type": "markdown", + "metadata": {}, + "source": [ + "Verify that the state dictionary contains no non-serialisable objects." + ] + }, { "cell_type": "code", - "execution_count": 16, + "execution_count": 15, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "{'uuid': 'f0c06262-1bd9-49ee-81f8-793fb4a5e58e',\n", - " 'network': {'uuid': '455d6a1a-ca23-4135-b326-3ebf75022a45',\n", - " 'nodes': {'c7c91f06-f128-4891-84a2-83beceea3908': {'uuid': 'c7c91f06-f128-4891-84a2-83beceea3908',\n", + "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", + " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", + " 'nodes': {'1fa46446-6681-4e25-a3ba-c4c2cc564630': {'uuid': '1fa46446-6681-4e25-a3ba-c4c2cc564630',\n", " 'hostname': 'primaite_pc',\n", " 'operating_state': 0,\n", - " 'NICs': {'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2': {'uuid': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", + " 'NICs': {'09ca02eb-7733-492c-9eff-f0d6b6ebeeda': {'uuid': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", " 'ip_adress': '130.1.1.1',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': 'c3:08:90:23:29:cb',\n", + " 'mac_address': '5c:b6:26:c0:86:61',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': '04ffd1e8-dea7-47ad-a088-4856df055ed1',\n", - " 'folders': {'f1fdf2ae-6377-4417-a28a-3edb4058712d': {'uuid': 'f1fdf2ae-6377-4417-a28a-3edb4058712d',\n", + " 'file_system': {'uuid': '8b533e31-04e9-4838-839d-0656ace3e57a',\n", + " 'folders': {'b450c223-872c-4fe0-90cc-9da80973eaad': {'uuid': 'b450c223-872c-4fe0-90cc-9da80973eaad',\n", " 'name': 'downloads',\n", " 'size': 1000.0,\n", - " 'files': {'409b09a3-0d98-4c03-adf2-09190539be45': {'uuid': '409b09a3-0d98-4c03-adf2-09190539be45',\n", + " 'files': {'8160e685-a76f-4171-8a12-3d6b32a9ea16': {'uuid': '8160e685-a76f-4171-8a12-3d6b32a9ea16',\n", " 'name': 'firefox_installer.zip',\n", " 'size': 1000.0,\n", " 'file_type': 'ZIP'}},\n", " 'is_quarantined': False}}},\n", - " 'applications': {'cddee888-d1b9-4289-8512-bc0a6672c880': {'uuid': 'cddee888-d1b9-4289-8512-bc0a6672c880',\n", + " 'applications': {'c82f1064-f35e-466b-88ae-3f61ba0e5161': {'uuid': 'c82f1064-f35e-466b-88ae-3f61ba0e5161',\n", " 'health_state': 'GOOD',\n", " 'health_state_red_view': 'GOOD',\n", " 'criticality': 'MEDIUM',\n", @@ -404,29 +312,29 @@ " 'groups': []}},\n", " 'services': {},\n", " 'process': {}},\n", - " 'dfcc395a-93ff-4dd5-9684-c80c5885d827': {'uuid': 'dfcc395a-93ff-4dd5-9684-c80c5885d827',\n", + " '7f637689-6f91-4026-a685-48a9067f03e8': {'uuid': '7f637689-6f91-4026-a685-48a9067f03e8',\n", " 'hostname': 'google_server',\n", " 'operating_state': 0,\n", - " 'NICs': {'1fd281a0-83ae-49d9-9b40-6aae7b465cab': {'uuid': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", + " 'NICs': {'1abc7272-c516-4463-bd07-1a3cefe39313': {'uuid': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", " 'ip_adress': '130.1.1.2',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': '69:50:cb:76:22:10',\n", + " 'mac_address': 'f6:de:1e:63:8e:7f',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': 'aea8f406-05de-4a02-b65f-972aa1fed70e',\n", - " 'folders': {'beb5b535-cf6c-431d-94f6-d1097910130d': {'uuid': 'beb5b535-cf6c-431d-94f6-d1097910130d',\n", + " 'file_system': {'uuid': 'ac9a6643-8349-4f7a-98c7-a1a9f97ce123',\n", + " 'folders': {'befa5d92-0878-4da2-9dac-f993c0b4a554': {'uuid': 'befa5d92-0878-4da2-9dac-f993c0b4a554',\n", " 'name': 'static',\n", " 'size': 0,\n", " 'files': {},\n", " 'is_quarantined': False},\n", - " '6644cd6c-1eca-4fe4-9313-e3481abb895e': {'uuid': '6644cd6c-1eca-4fe4-9313-e3481abb895e',\n", + " '27383b5e-8884-4ec0-bb50-a5d43e460dfa': {'uuid': '27383b5e-8884-4ec0-bb50-a5d43e460dfa',\n", " 'name': 'root',\n", " 'size': 40.0,\n", - " 'files': {'3ecf7223-dafd-4973-8c3b-b85af4e177da': {'uuid': '3ecf7223-dafd-4973-8c3b-b85af4e177da',\n", + " 'files': {'253e4606-0f6d-4e57-8db0-6fa7e331ecea': {'uuid': '253e4606-0f6d-4e57-8db0-6fa7e331ecea',\n", " 'name': 'favicon.ico',\n", " 'size': 40.0,\n", " 'file_type': 'PNG'}},\n", @@ -434,18 +342,18 @@ " 'applications': {},\n", " 'services': {},\n", " 'process': {}}},\n", - " 'links': {'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9': {'uuid': 'cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9',\n", - " 'endpoint_a': 'f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2',\n", - " 'endpoint_b': '4e6abc87-b4b9-4f95-a9a9-59cac130c6ff',\n", + " 'links': {'a449b1ff-50d9-4342-861e-44f2d4dfef37': {'uuid': 'a449b1ff-50d9-4342-861e-44f2d4dfef37',\n", + " 'endpoint_a': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", + " 'endpoint_b': 'ee4557d9-a309-45dd-a6e0-5b572cc70ee5',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0},\n", - " '2dab7fc3-470d-44d2-8593-feb8e96d71ae': {'uuid': '2dab7fc3-470d-44d2-8593-feb8e96d71ae',\n", - " 'endpoint_a': '1fd281a0-83ae-49d9-9b40-6aae7b465cab',\n", - " 'endpoint_b': 'e136553f-333e-4abf-b1f3-ce352ffa4630',\n", + " 'ebd7687b-ec69-4f1b-b2ba-86669aa95723': {'uuid': 'ebd7687b-ec69-4f1b-b2ba-86669aa95723',\n", + " 'endpoint_a': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", + " 'endpoint_b': 'dc26b764-a07e-486a-99a4-798c8e0c187a',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '9da912d5-4c07-4df6-94c2-b3630e178912',\n", - " 'accounts': {'563a1805-0b32-4ba6-9551-e127e5eb57a8': {'uuid': '563a1805-0b32-4ba6-9551-e127e5eb57a8',\n", + " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a',\n", + " 'accounts': {'5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51': {'uuid': '5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51',\n", " 'num_logons': 0,\n", " 'num_logoffs': 0,\n", " 'num_group_changes': 0,\n", @@ -455,7 +363,7 @@ " 'enabled': True}}}}" ] }, - "execution_count": 16, + "execution_count": 15, "metadata": {}, "output_type": "execute_result" } @@ -464,41 +372,25 @@ "my_sim.describe_state()" ] }, - { - "cell_type": "markdown", - "metadata": {}, - "source": [ - "Verify that the state dictionary contains no non-serialisable objects." - ] - }, { "cell_type": "code", - "execution_count": 17, - "metadata": {}, - "outputs": [], - "source": [ - "import json" - ] - }, - { - "cell_type": "code", - "execution_count": 22, + "execution_count": 16, "metadata": {}, "outputs": [ { "data": { "text/plain": [ - "'{\"uuid\": \"f0c06262-1bd9-49ee-81f8-793fb4a5e58e\", \"network\": {\"uuid\": \"455d6a1a-ca23-4135-b326-3ebf75022a45\", \"nodes\": {\"c7c91f06-f128-4891-84a2-83beceea3908\": {\"uuid\": \"c7c91f06-f128-4891-84a2-83beceea3908\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\": {\"uuid\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"c3:08:90:23:29:cb\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"04ffd1e8-dea7-47ad-a088-4856df055ed1\", \"folders\": {\"f1fdf2ae-6377-4417-a28a-3edb4058712d\": {\"uuid\": \"f1fdf2ae-6377-4417-a28a-3edb4058712d\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"409b09a3-0d98-4c03-adf2-09190539be45\": {\"uuid\": \"409b09a3-0d98-4c03-adf2-09190539be45\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"cddee888-d1b9-4289-8512-bc0a6672c880\": {\"uuid\": \"cddee888-d1b9-4289-8512-bc0a6672c880\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"dfcc395a-93ff-4dd5-9684-c80c5885d827\": {\"uuid\": \"dfcc395a-93ff-4dd5-9684-c80c5885d827\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1fd281a0-83ae-49d9-9b40-6aae7b465cab\": {\"uuid\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"69:50:cb:76:22:10\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"aea8f406-05de-4a02-b65f-972aa1fed70e\", \"folders\": {\"beb5b535-cf6c-431d-94f6-d1097910130d\": {\"uuid\": \"beb5b535-cf6c-431d-94f6-d1097910130d\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"6644cd6c-1eca-4fe4-9313-e3481abb895e\": {\"uuid\": \"6644cd6c-1eca-4fe4-9313-e3481abb895e\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"3ecf7223-dafd-4973-8c3b-b85af4e177da\": {\"uuid\": \"3ecf7223-dafd-4973-8c3b-b85af4e177da\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\": {\"uuid\": \"cfbf3c88-c2ac-40f2-9466-a92b99f7cfc9\", \"endpoint_a\": \"f181ea2b-59b6-4724-acf1-a8a7d4e2a1b2\", \"endpoint_b\": \"4e6abc87-b4b9-4f95-a9a9-59cac130c6ff\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\": {\"uuid\": \"2dab7fc3-470d-44d2-8593-feb8e96d71ae\", \"endpoint_a\": \"1fd281a0-83ae-49d9-9b40-6aae7b465cab\", \"endpoint_b\": \"e136553f-333e-4abf-b1f3-ce352ffa4630\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"9da912d5-4c07-4df6-94c2-b3630e178912\", \"accounts\": {\"563a1805-0b32-4ba6-9551-e127e5eb57a8\": {\"uuid\": \"563a1805-0b32-4ba6-9551-e127e5eb57a8\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + "'{\"uuid\": \"5304ed6d-de4c-408c-ae24-ada32852d196\", \"network\": {\"uuid\": \"fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756\", \"nodes\": {\"1fa46446-6681-4e25-a3ba-c4c2cc564630\": {\"uuid\": \"1fa46446-6681-4e25-a3ba-c4c2cc564630\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\": {\"uuid\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"5c:b6:26:c0:86:61\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"8b533e31-04e9-4838-839d-0656ace3e57a\", \"folders\": {\"b450c223-872c-4fe0-90cc-9da80973eaad\": {\"uuid\": \"b450c223-872c-4fe0-90cc-9da80973eaad\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"8160e685-a76f-4171-8a12-3d6b32a9ea16\": {\"uuid\": \"8160e685-a76f-4171-8a12-3d6b32a9ea16\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"c82f1064-f35e-466b-88ae-3f61ba0e5161\": {\"uuid\": \"c82f1064-f35e-466b-88ae-3f61ba0e5161\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7f637689-6f91-4026-a685-48a9067f03e8\": {\"uuid\": \"7f637689-6f91-4026-a685-48a9067f03e8\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1abc7272-c516-4463-bd07-1a3cefe39313\": {\"uuid\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"f6:de:1e:63:8e:7f\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"ac9a6643-8349-4f7a-98c7-a1a9f97ce123\", \"folders\": {\"befa5d92-0878-4da2-9dac-f993c0b4a554\": {\"uuid\": \"befa5d92-0878-4da2-9dac-f993c0b4a554\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\": {\"uuid\": \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"253e4606-0f6d-4e57-8db0-6fa7e331ecea\": {\"uuid\": \"253e4606-0f6d-4e57-8db0-6fa7e331ecea\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"a449b1ff-50d9-4342-861e-44f2d4dfef37\": {\"uuid\": \"a449b1ff-50d9-4342-861e-44f2d4dfef37\", \"endpoint_a\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"endpoint_b\": \"ee4557d9-a309-45dd-a6e0-5b572cc70ee5\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\": {\"uuid\": \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\", \"endpoint_a\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"endpoint_b\": \"dc26b764-a07e-486a-99a4-798c8e0c187a\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"320cbb83-eb1b-4911-a4f0-fc46d8038a8a\", \"accounts\": {\"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\": {\"uuid\": \"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" ] }, - "execution_count": 22, + "execution_count": 16, "metadata": {}, "output_type": "execute_result" } ], "source": [ - "d = my_sim.describe_state()\n", - "json.dumps(d)" + "import json\n", + "json.dumps(my_sim.describe_state())" ] } ], From 07b740a81e9872a62aa3326520fa50421c2c6186 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 21 Aug 2023 09:49:31 +0100 Subject: [PATCH 15/55] Update docs and changelog. --- CHANGELOG.md | 2 ++ docs/_static/component_relationship.png | Bin 0 -> 82446 bytes docs/source/simulation_structure.rst | 14 +++++++++++--- 3 files changed, 13 insertions(+), 3 deletions(-) create mode 100644 docs/_static/component_relationship.png diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f3f57fb..2b495c09 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,8 @@ a Service/Application another machine. SessionManager. - Permission System - each action can define criteria that will be used to permit or deny agent actions. - File System - ability to emulate a node's file system during a simulation +- Example notebooks - There is currently 1 jupyter notebook which walks through using PrimAITE + 1. Creating a simulation - this notebook explains how to build up a simulation using the Python package. (WIP) ## [2.0.0] - 2023-07-26 diff --git a/docs/_static/component_relationship.png b/docs/_static/component_relationship.png new file mode 100644 index 0000000000000000000000000000000000000000..c2dd1102d203e7f99081dc1e0f953729aaa2e314 GIT binary patch literal 82446 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 =|845Vs4X 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 literal 0 HcmV?d00001 diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst index 2b213f16..7630ae0f 100644 --- a/docs/source/simulation_structure.rst +++ b/docs/source/simulation_structure.rst @@ -7,11 +7,19 @@ Simulation Structure ==================== The simulation is made up of many smaller components which are related to each other in a tree-like structure. At the -top level, there is an object called the ``SimulationController`` _(doesn't exist yet)_, which has a physical network -and a software controller for managing software and users. +top level, there is the :py:meth:`primaite.simulator.sim_container.Simulation`, which keeps track of the physical network +and a domain controller for managing software and users. -Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. +Each node of the simulation 'tree' has responsibility for creating, deleting, and updating its direct descendants. Also, +when a component's ``describe_state()`` method is called, it will include the state of its descendants. The +``apply_action()`` method can be used to act on a component or one of its descendatnts. The diagram below shows the +relationship between components. +.. image:: _static/component_relationship.png + :width: 500 + :alt: The top level simulation object owns a NetworkContainer and a DomainController. The DomainController has a + list of accounts. The network container has links and nodes. Nodes can own switchports, NICs, FileSystem, + Application, Service, and Process. Actions From 7e64acd36840363be507ea36924d4eeb93f2a07e Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 21 Aug 2023 10:04:23 +0100 Subject: [PATCH 16/55] Update container docstrings --- src/primaite/simulator/network/container.py | 3 ++- src/primaite/simulator/sim_container.py | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 463d5f91..f89ed2d3 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -5,12 +5,13 @@ from primaite.simulator.network.hardware.base import Link, Node class NetworkContainer(SimComponent): - """TODO.""" + """Top level container object representing the physical network.""" nodes: Dict[str, Node] = {} links: Dict[str, Link] = {} def __init__(self, **kwargs): + """Initialise the network.""" super().__init__(**kwargs) self.action_manager = ActionManager() diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 1a37dc18..50fe412c 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -6,12 +6,13 @@ from primaite.simulator.network.container import NetworkContainer class Simulation(SimComponent): - """TODO.""" + """Top-level simulation object which holds a reference to all other parts of the simulation.""" network: NetworkContainer domain: DomainController def __init__(self, **kwargs): + """Initialise the Simulation.""" if not kwargs.get("network"): kwargs["network"] = NetworkContainer() From 1613bbe27afece0621614b1931daae38848c24e4 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 23 Aug 2023 14:41:30 +0100 Subject: [PATCH 17/55] Add methods for adding/removing nodes form network --- .../notebooks/create-simulation.ipynb | 200 +++--------------- src/primaite/simulator/core.py | 22 ++ src/primaite/simulator/network/container.py | 47 +++- 3 files changed, 93 insertions(+), 176 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index b0a140a1..11d41356 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -18,7 +18,7 @@ }, { "cell_type": "code", - "execution_count": 1, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -36,26 +36,12 @@ }, { "cell_type": "code", - "execution_count": 2, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", - " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", - " 'nodes': {},\n", - " 'links': {}},\n", - " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a', 'accounts': {}}}" - ] - }, - "execution_count": 2, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_sim = Simulation()\n", + "net = my_sim.network\n", "my_sim.describe_state()" ] }, @@ -68,7 +54,7 @@ }, { "cell_type": "code", - "execution_count": 3, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -77,17 +63,14 @@ }, { "cell_type": "code", - "execution_count": 4, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ "my_pc = Node(hostname=\"primaite_pc\",)\n", + "net.add_node(my_pc)\n", "my_server = Node(hostname=\"google_server\")\n", - "\n", - "# TODO: when there is a proper function for adding nodes, use it instead of manually adding.\n", - "\n", - "my_sim.network.nodes[my_pc.uuid] = my_pc\n", - "my_sim.network.nodes[my_server.uuid] = my_server\n" + "net.add_node(my_server)\n" ] }, { @@ -99,7 +82,7 @@ }, { "cell_type": "code", - "execution_count": 5, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -108,22 +91,12 @@ }, { "cell_type": "code", - "execution_count": 6, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "name": "stderr", - "output_type": "stream", - "text": [ - "2023-08-20 18:42:51,310: NIC 5c:b6:26:c0:86:61/130.1.1.1 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", - "2023-08-20 18:42:51,311: SwitchPort 01:ef:b1:a3:24:72 connected to Link 5c:b6:26:c0:86:61/130.1.1.1<-->01:ef:b1:a3:24:72\n", - "2023-08-20 18:42:51,314: NIC f6:de:1e:63:8e:7f/130.1.1.2 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n", - "2023-08-20 18:42:51,315: SwitchPort 30:9e:c8:d4:5d:f3 connected to Link f6:de:1e:63:8e:7f/130.1.1.2<-->30:9e:c8:d4:5d:f3\n" - ] - } - ], + "outputs": [], "source": [ "my_swtich = Switch(hostname=\"switch1\", num_ports=12)\n", + "net.add_node(my_swtich)\n", "\n", "pc_nic = NIC(ip_address=\"130.1.1.1\", gateway=\"130.1.1.255\", subnet_mask=\"255.255.255.0\")\n", "my_pc.connect_nic(pc_nic)\n", @@ -149,7 +122,7 @@ }, { "cell_type": "code", - "execution_count": 7, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -159,7 +132,7 @@ }, { "cell_type": "code", - "execution_count": 8, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -169,20 +142,9 @@ }, { "cell_type": "code", - "execution_count": 9, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "FileSystemFile(uuid='253e4606-0f6d-4e57-8db0-6fa7e331ecea', name='favicon.ico', size=40.0, file_type=, action_manager=None)" - ] - }, - "execution_count": 9, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_server_folder = my_server.file_system.create_folder(\"static\")\n", "my_server.file_system.create_file(\"favicon.ico\", file_type=FileSystemFileType.PNG)" @@ -197,7 +159,7 @@ }, { "cell_type": "code", - "execution_count": 10, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -213,7 +175,7 @@ }, { "cell_type": "code", - "execution_count": 11, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -222,7 +184,7 @@ }, { "cell_type": "code", - "execution_count": 12, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -238,7 +200,7 @@ }, { "cell_type": "code", - "execution_count": 13, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -247,7 +209,7 @@ }, { "cell_type": "code", - "execution_count": 14, + "execution_count": null, "metadata": {}, "outputs": [], "source": [ @@ -264,130 +226,18 @@ }, { "cell_type": "code", - "execution_count": 15, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "{'uuid': '5304ed6d-de4c-408c-ae24-ada32852d196',\n", - " 'network': {'uuid': 'fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756',\n", - " 'nodes': {'1fa46446-6681-4e25-a3ba-c4c2cc564630': {'uuid': '1fa46446-6681-4e25-a3ba-c4c2cc564630',\n", - " 'hostname': 'primaite_pc',\n", - " 'operating_state': 0,\n", - " 'NICs': {'09ca02eb-7733-492c-9eff-f0d6b6ebeeda': {'uuid': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", - " 'ip_adress': '130.1.1.1',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': '5c:b6:26:c0:86:61',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': '8b533e31-04e9-4838-839d-0656ace3e57a',\n", - " 'folders': {'b450c223-872c-4fe0-90cc-9da80973eaad': {'uuid': 'b450c223-872c-4fe0-90cc-9da80973eaad',\n", - " 'name': 'downloads',\n", - " 'size': 1000.0,\n", - " 'files': {'8160e685-a76f-4171-8a12-3d6b32a9ea16': {'uuid': '8160e685-a76f-4171-8a12-3d6b32a9ea16',\n", - " 'name': 'firefox_installer.zip',\n", - " 'size': 1000.0,\n", - " 'file_type': 'ZIP'}},\n", - " 'is_quarantined': False}}},\n", - " 'applications': {'c82f1064-f35e-466b-88ae-3f61ba0e5161': {'uuid': 'c82f1064-f35e-466b-88ae-3f61ba0e5161',\n", - " 'health_state': 'GOOD',\n", - " 'health_state_red_view': 'GOOD',\n", - " 'criticality': 'MEDIUM',\n", - " 'patching_count': 0,\n", - " 'scanning_count': 0,\n", - " 'revealed_to_red': False,\n", - " 'installing_count': 0,\n", - " 'max_sessions': 1,\n", - " 'tcp': True,\n", - " 'udp': True,\n", - " 'ports': ['HTTP'],\n", - " 'opearting_state': 'RUNNING',\n", - " 'execution_control_status': 'manual',\n", - " 'num_executions': 0,\n", - " 'groups': []}},\n", - " 'services': {},\n", - " 'process': {}},\n", - " '7f637689-6f91-4026-a685-48a9067f03e8': {'uuid': '7f637689-6f91-4026-a685-48a9067f03e8',\n", - " 'hostname': 'google_server',\n", - " 'operating_state': 0,\n", - " 'NICs': {'1abc7272-c516-4463-bd07-1a3cefe39313': {'uuid': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", - " 'ip_adress': '130.1.1.2',\n", - " 'subnet_mask': '255.255.255.0',\n", - " 'gateway': '130.1.1.255',\n", - " 'mac_address': 'f6:de:1e:63:8e:7f',\n", - " 'speed': 100,\n", - " 'mtu': 1500,\n", - " 'wake_on_lan': False,\n", - " 'dns_servers': [],\n", - " 'enabled': False}},\n", - " 'file_system': {'uuid': 'ac9a6643-8349-4f7a-98c7-a1a9f97ce123',\n", - " 'folders': {'befa5d92-0878-4da2-9dac-f993c0b4a554': {'uuid': 'befa5d92-0878-4da2-9dac-f993c0b4a554',\n", - " 'name': 'static',\n", - " 'size': 0,\n", - " 'files': {},\n", - " 'is_quarantined': False},\n", - " '27383b5e-8884-4ec0-bb50-a5d43e460dfa': {'uuid': '27383b5e-8884-4ec0-bb50-a5d43e460dfa',\n", - " 'name': 'root',\n", - " 'size': 40.0,\n", - " 'files': {'253e4606-0f6d-4e57-8db0-6fa7e331ecea': {'uuid': '253e4606-0f6d-4e57-8db0-6fa7e331ecea',\n", - " 'name': 'favicon.ico',\n", - " 'size': 40.0,\n", - " 'file_type': 'PNG'}},\n", - " 'is_quarantined': False}}},\n", - " 'applications': {},\n", - " 'services': {},\n", - " 'process': {}}},\n", - " 'links': {'a449b1ff-50d9-4342-861e-44f2d4dfef37': {'uuid': 'a449b1ff-50d9-4342-861e-44f2d4dfef37',\n", - " 'endpoint_a': '09ca02eb-7733-492c-9eff-f0d6b6ebeeda',\n", - " 'endpoint_b': 'ee4557d9-a309-45dd-a6e0-5b572cc70ee5',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0},\n", - " 'ebd7687b-ec69-4f1b-b2ba-86669aa95723': {'uuid': 'ebd7687b-ec69-4f1b-b2ba-86669aa95723',\n", - " 'endpoint_a': '1abc7272-c516-4463-bd07-1a3cefe39313',\n", - " 'endpoint_b': 'dc26b764-a07e-486a-99a4-798c8e0c187a',\n", - " 'bandwidth': 100.0,\n", - " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '320cbb83-eb1b-4911-a4f0-fc46d8038a8a',\n", - " 'accounts': {'5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51': {'uuid': '5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51',\n", - " 'num_logons': 0,\n", - " 'num_logoffs': 0,\n", - " 'num_group_changes': 0,\n", - " 'username': 'admin',\n", - " 'password': 'admin12',\n", - " 'account_type': 'USER',\n", - " 'enabled': True}}}}" - ] - }, - "execution_count": 15, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "my_sim.describe_state()" ] }, { "cell_type": "code", - "execution_count": 16, + "execution_count": null, "metadata": {}, - "outputs": [ - { - "data": { - "text/plain": [ - "'{\"uuid\": \"5304ed6d-de4c-408c-ae24-ada32852d196\", \"network\": {\"uuid\": \"fa17dfe8-81a1-4c7f-8c5b-8c2d3b1e8756\", \"nodes\": {\"1fa46446-6681-4e25-a3ba-c4c2cc564630\": {\"uuid\": \"1fa46446-6681-4e25-a3ba-c4c2cc564630\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\": {\"uuid\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"5c:b6:26:c0:86:61\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"8b533e31-04e9-4838-839d-0656ace3e57a\", \"folders\": {\"b450c223-872c-4fe0-90cc-9da80973eaad\": {\"uuid\": \"b450c223-872c-4fe0-90cc-9da80973eaad\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"8160e685-a76f-4171-8a12-3d6b32a9ea16\": {\"uuid\": \"8160e685-a76f-4171-8a12-3d6b32a9ea16\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"c82f1064-f35e-466b-88ae-3f61ba0e5161\": {\"uuid\": \"c82f1064-f35e-466b-88ae-3f61ba0e5161\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7f637689-6f91-4026-a685-48a9067f03e8\": {\"uuid\": \"7f637689-6f91-4026-a685-48a9067f03e8\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"1abc7272-c516-4463-bd07-1a3cefe39313\": {\"uuid\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"f6:de:1e:63:8e:7f\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"ac9a6643-8349-4f7a-98c7-a1a9f97ce123\", \"folders\": {\"befa5d92-0878-4da2-9dac-f993c0b4a554\": {\"uuid\": \"befa5d92-0878-4da2-9dac-f993c0b4a554\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\": {\"uuid\": \"27383b5e-8884-4ec0-bb50-a5d43e460dfa\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"253e4606-0f6d-4e57-8db0-6fa7e331ecea\": {\"uuid\": \"253e4606-0f6d-4e57-8db0-6fa7e331ecea\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}}, \"links\": {\"a449b1ff-50d9-4342-861e-44f2d4dfef37\": {\"uuid\": \"a449b1ff-50d9-4342-861e-44f2d4dfef37\", \"endpoint_a\": \"09ca02eb-7733-492c-9eff-f0d6b6ebeeda\", \"endpoint_b\": \"ee4557d9-a309-45dd-a6e0-5b572cc70ee5\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\": {\"uuid\": \"ebd7687b-ec69-4f1b-b2ba-86669aa95723\", \"endpoint_a\": \"1abc7272-c516-4463-bd07-1a3cefe39313\", \"endpoint_b\": \"dc26b764-a07e-486a-99a4-798c8e0c187a\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"320cbb83-eb1b-4911-a4f0-fc46d8038a8a\", \"accounts\": {\"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\": {\"uuid\": \"5fdcfb66-84f3-4f0f-a3a7-d0cb0e1a5d51\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" - ] - }, - "execution_count": 16, - "metadata": {}, - "output_type": "execute_result" - } - ], + "outputs": [], "source": [ "import json\n", "json.dumps(my_sim.describe_state())" diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 2c802c0f..63120ecf 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -137,6 +137,7 @@ class SimComponent(BaseModel): kwargs["uuid"] = str(uuid4()) super().__init__(**kwargs) self.action_manager: Optional[ActionManager] = None + self._parent: Optional["SimComponent"] = None @abstractmethod def describe_state(self) -> Dict: @@ -187,3 +188,24 @@ class SimComponent(BaseModel): Override this method with anything that needs to happen within the component for it to be reset. """ pass + + @property + def parent(self) -> "SimComponent": + """Reference to the parent object which manages this object. + + :return: Parent object. + :rtype: SimComponent + """ + return self._parent + + @parent.setter + def parent(self, new_parent: "SimComponent") -> None: + if self._parent: + msg = f"Overwriting parent of {self}, {self._parent} with {new_parent}" + _LOGGER.warn(msg) + raise RuntimeWarning(msg) + self._parent = new_parent + + @parent.deleter + def parent(self) -> None: + self._parent = None diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index f89ed2d3..be2a3bbb 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,8 +1,11 @@ -from typing import Dict +from typing import Any, Dict +from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent from primaite.simulator.network.hardware.base import Link, Node +_LOGGER = getLogger(__name__) + class NetworkContainer(SimComponent): """Top level container object representing the physical network.""" @@ -40,3 +43,45 @@ class NetworkContainer(SimComponent): } ) return state + + def add_node(self, node: Node) -> None: + """ + Add an existing node to the network. + + :param node: Node instance that the network should keep track of. + :type node: Node + """ + if node in self: + _LOGGER.warning(f"Can't add node {node}. It is already in the network.") + self.nodes[node.uuid] = node + node.parent = self + + def remove_node(self, node: Node) -> None: + """ + Remove a node from the network. + + :param node: Node instance that is currently part of the network that should be removed. + :type node: Node + """ + if node not in self: + _LOGGER.warning(f"Can't remove node {node}. It's not in the network.") + del self.nodes[node.uuid] + del node.parent # misleading? + + def connect_nodes(self, node1: Node, node2: Node) -> None: + """TODO.""" + # I think we should not be forcing users to add and remove individual links. + # Clearly if a link exists between two nodes in the network, then the link is also part of the network. + # I'm just not sure how we ought to handle link creation as it requires an unoccupied network device on the node + raise NotImplementedError + + def disconnect_nodes(self, node1: Node, node2: Node) -> None: + """TODO.""" + raise NotImplementedError + + def __contains__(self, item: Any) -> bool: + if isinstance(item, Node): + return item.uuid in self.nodes + elif isinstance(item, Link): + return item.uuid in self.links + raise TypeError("") From 72b019287aef95c014a36b5e1764aba208da7ff6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 23 Aug 2023 14:41:59 +0100 Subject: [PATCH 18/55] Add scratch notebook to gitignore. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 60f5f54c..ff86b65f 100644 --- a/.gitignore +++ b/.gitignore @@ -150,3 +150,4 @@ src/primaite/outputs/ # benchmark session outputs benchmark/output +src/primaite/notebooks/scratch.ipynb From a82ffb974717273963482d9162579538dac2ffb8 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Wed, 23 Aug 2023 15:44:23 +0100 Subject: [PATCH 19/55] Add notebook outputs back into src control --- .../notebooks/create-simulation.ipynb | 254 ++++++++++++++++-- 1 file changed, 233 insertions(+), 21 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index 11d41356..e3e7dfb7 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -18,7 +18,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 1, "metadata": {}, "outputs": [], "source": [ @@ -36,9 +36,24 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 2, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", + " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", + " 'nodes': {},\n", + " 'links': {}},\n", + " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e', 'accounts': {}}}" + ] + }, + "execution_count": 2, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "my_sim = Simulation()\n", "net = my_sim.network\n", @@ -54,7 +69,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 3, "metadata": {}, "outputs": [], "source": [ @@ -63,7 +78,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 4, "metadata": {}, "outputs": [], "source": [ @@ -82,7 +97,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 5, "metadata": {}, "outputs": [], "source": [ @@ -91,9 +106,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 6, "metadata": {}, - "outputs": [], + "outputs": [ + { + "name": "stderr", + "output_type": "stream", + "text": [ + "2023-08-23 15:44:02,059: NIC 1b:8f:94:4f:46:99/130.1.1.1 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", + "2023-08-23 15:44:02,062: SwitchPort ad:3c:77:44:98:27 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", + "2023-08-23 15:44:02,064: NIC 50:f4:6b:9b:a8:74/130.1.1.2 connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n", + "2023-08-23 15:44:02,065: SwitchPort fd:b1:68:f9:8f:eb connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n" + ] + } + ], "source": [ "my_swtich = Switch(hostname=\"switch1\", num_ports=12)\n", "net.add_node(my_swtich)\n", @@ -122,7 +148,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 7, "metadata": {}, "outputs": [], "source": [ @@ -132,7 +158,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 8, "metadata": {}, "outputs": [], "source": [ @@ -142,9 +168,20 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 9, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "FileSystemFile(uuid='f45bffd7-4aa1-4f6f-81ba-85e746abd28b', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + ] + }, + "execution_count": 9, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "my_server_folder = my_server.file_system.create_folder(\"static\")\n", "my_server.file_system.create_file(\"favicon.ico\", file_type=FileSystemFileType.PNG)" @@ -159,7 +196,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 10, "metadata": {}, "outputs": [], "source": [ @@ -175,7 +212,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 11, "metadata": {}, "outputs": [], "source": [ @@ -184,7 +221,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 12, "metadata": {}, "outputs": [], "source": [ @@ -200,7 +237,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 13, "metadata": {}, "outputs": [], "source": [ @@ -209,7 +246,7 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 14, "metadata": {}, "outputs": [], "source": [ @@ -226,18 +263,193 @@ }, { "cell_type": "code", - "execution_count": null, + "execution_count": 15, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", + " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", + " 'nodes': {'1599c08e-a101-41a7-a86a-4176660c4270': {'uuid': '1599c08e-a101-41a7-a86a-4176660c4270',\n", + " 'hostname': 'primaite_pc',\n", + " 'operating_state': 0,\n", + " 'NICs': {'ab09d298-ac44-40ef-b950-b4ca6268d482': {'uuid': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", + " 'ip_adress': '130.1.1.1',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '1b:8f:94:4f:46:99',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '92120387-14cb-426c-98f2-64d64a85f560',\n", + " 'folders': {'6a11bd03-bc59-4da9-8474-639fcb72b9be': {'uuid': '6a11bd03-bc59-4da9-8474-639fcb72b9be',\n", + " 'name': 'downloads',\n", + " 'size': 1000.0,\n", + " 'files': {'194b2029-4723-4cff-b6d7-e647e4fb687d': {'uuid': '194b2029-4723-4cff-b6d7-e647e4fb687d',\n", + " 'name': 'firefox_installer.zip',\n", + " 'size': 1000.0,\n", + " 'file_type': 'ZIP'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {'ae49273b-f581-44e7-ae8c-18cc766158e8': {'uuid': 'ae49273b-f581-44e7-ae8c-18cc766158e8',\n", + " 'health_state': 'GOOD',\n", + " 'health_state_red_view': 'GOOD',\n", + " 'criticality': 'MEDIUM',\n", + " 'patching_count': 0,\n", + " 'scanning_count': 0,\n", + " 'revealed_to_red': False,\n", + " 'installing_count': 0,\n", + " 'max_sessions': 1,\n", + " 'tcp': True,\n", + " 'udp': True,\n", + " 'ports': ['HTTP'],\n", + " 'opearting_state': 'RUNNING',\n", + " 'execution_control_status': 'manual',\n", + " 'num_executions': 0,\n", + " 'groups': []}},\n", + " 'services': {},\n", + " 'process': {}},\n", + " '7231c745-e186-47a2-8f69-006033b38b8f': {'uuid': '7231c745-e186-47a2-8f69-006033b38b8f',\n", + " 'hostname': 'google_server',\n", + " 'operating_state': 0,\n", + " 'NICs': {'d138788b-2a8e-4c5c-aa5d-b5c28758a78a': {'uuid': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", + " 'ip_adress': '130.1.1.2',\n", + " 'subnet_mask': '255.255.255.0',\n", + " 'gateway': '130.1.1.255',\n", + " 'mac_address': '50:f4:6b:9b:a8:74',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'wake_on_lan': False,\n", + " 'dns_servers': [],\n", + " 'enabled': False}},\n", + " 'file_system': {'uuid': '85f1d50c-ded7-4160-9a11-1305ab25934b',\n", + " 'folders': {'86c2666e-31da-46a9-a267-4dc87e2620f9': {'uuid': '86c2666e-31da-46a9-a267-4dc87e2620f9',\n", + " 'name': 'static',\n", + " 'size': 0,\n", + " 'files': {},\n", + " 'is_quarantined': False},\n", + " '1a4479df-6f52-428c-b7b9-c026ab24d2a3': {'uuid': '1a4479df-6f52-428c-b7b9-c026ab24d2a3',\n", + " 'name': 'root',\n", + " 'size': 40.0,\n", + " 'files': {'f45bffd7-4aa1-4f6f-81ba-85e746abd28b': {'uuid': 'f45bffd7-4aa1-4f6f-81ba-85e746abd28b',\n", + " 'name': 'favicon.ico',\n", + " 'size': 40.0,\n", + " 'file_type': 'PNG'}},\n", + " 'is_quarantined': False}}},\n", + " 'applications': {},\n", + " 'services': {},\n", + " 'process': {}},\n", + " '384bab1c-aa23-49cf-9c4e-caababcf30a0': {'uuid': '384bab1c-aa23-49cf-9c4e-caababcf30a0',\n", + " 'num_ports': 12,\n", + " 'ports': {1: {'uuid': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", + " 'mac_address': 'ad:3c:77:44:98:27',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 2: {'uuid': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", + " 'mac_address': 'fd:b1:68:f9:8f:eb',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 3: {'uuid': '8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1',\n", + " 'mac_address': 'bb:ba:58:26:52:2d',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 4: {'uuid': '3cde63c0-38e4-4faa-88ba-3a958118e2b3',\n", + " 'mac_address': '69:bc:6f:e1:30:32',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 5: {'uuid': '37e49743-1723-4b0e-a1e5-61d76e230c08',\n", + " 'mac_address': 'd3:a0:8b:92:25:11',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 6: {'uuid': '3bf0c0c4-27f6-4a90-8279-1f713b46f4bf',\n", + " 'mac_address': '48:88:7c:71:0a:c0',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 7: {'uuid': '40b0ba34-9e70-448a-8fdf-836a5a71ed8f',\n", + " 'mac_address': '24:81:03:09:c0:be',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 8: {'uuid': 'cd23d94b-84b8-441c-bd95-4e310682a095',\n", + " 'mac_address': '27:18:c5:47:fd:82',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 9: {'uuid': '608eb5bd-7875-4b64-a6f8-794e6283a305',\n", + " 'mac_address': '03:dd:34:d2:56:1c',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 10: {'uuid': '4acb48c6-74be-40d3-b706-64c06c55720b',\n", + " 'mac_address': 'a3:55:83:af:b7:6b',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 11: {'uuid': '73e989b5-3c2c-4035-8191-47220ea5ca43',\n", + " 'mac_address': '4f:60:84:21:50:6d',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False},\n", + " 12: {'uuid': '961ff733-a07c-433b-9433-8418a3761120',\n", + " 'mac_address': '7a:26:02:14:8d:da',\n", + " 'speed': 100,\n", + " 'mtu': 1500,\n", + " 'enabled': False}},\n", + " 'mac_address_table': {}}},\n", + " 'links': {'67df55f4-c485-4eed-a4dc-fe6f96f6b2f3': {'uuid': '67df55f4-c485-4eed-a4dc-fe6f96f6b2f3',\n", + " 'endpoint_a': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", + " 'endpoint_b': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0},\n", + " '4fdb61da-7cc9-43ea-9ee6-7d9853deff72': {'uuid': '4fdb61da-7cc9-43ea-9ee6-7d9853deff72',\n", + " 'endpoint_a': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", + " 'endpoint_b': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", + " 'bandwidth': 100.0,\n", + " 'current_load': 0.0}}},\n", + " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e',\n", + " 'accounts': {'d7f5bd32-5071-4bec-a111-a9f4e1aca45a': {'uuid': 'd7f5bd32-5071-4bec-a111-a9f4e1aca45a',\n", + " 'num_logons': 0,\n", + " 'num_logoffs': 0,\n", + " 'num_group_changes': 0,\n", + " 'username': 'admin',\n", + " 'password': 'admin12',\n", + " 'account_type': 'USER',\n", + " 'enabled': True}}}}" + ] + }, + "execution_count": 15, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "my_sim.describe_state()" ] }, { "cell_type": "code", - "execution_count": null, + "execution_count": 16, "metadata": {}, - "outputs": [], + "outputs": [ + { + "data": { + "text/plain": [ + "'{\"uuid\": \"95929b6a-1ce4-4c94-966c-6d3246d7caf9\", \"network\": {\"uuid\": \"4b41398e-d768-47c5-80cf-4278cfc35a24\", \"nodes\": {\"1599c08e-a101-41a7-a86a-4176660c4270\": {\"uuid\": \"1599c08e-a101-41a7-a86a-4176660c4270\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"ab09d298-ac44-40ef-b950-b4ca6268d482\": {\"uuid\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"1b:8f:94:4f:46:99\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"92120387-14cb-426c-98f2-64d64a85f560\", \"folders\": {\"6a11bd03-bc59-4da9-8474-639fcb72b9be\": {\"uuid\": \"6a11bd03-bc59-4da9-8474-639fcb72b9be\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"194b2029-4723-4cff-b6d7-e647e4fb687d\": {\"uuid\": \"194b2029-4723-4cff-b6d7-e647e4fb687d\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"ae49273b-f581-44e7-ae8c-18cc766158e8\": {\"uuid\": \"ae49273b-f581-44e7-ae8c-18cc766158e8\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7231c745-e186-47a2-8f69-006033b38b8f\": {\"uuid\": \"7231c745-e186-47a2-8f69-006033b38b8f\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\": {\"uuid\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"50:f4:6b:9b:a8:74\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"85f1d50c-ded7-4160-9a11-1305ab25934b\", \"folders\": {\"86c2666e-31da-46a9-a267-4dc87e2620f9\": {\"uuid\": \"86c2666e-31da-46a9-a267-4dc87e2620f9\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\": {\"uuid\": \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\": {\"uuid\": \"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}, \"384bab1c-aa23-49cf-9c4e-caababcf30a0\": {\"uuid\": \"384bab1c-aa23-49cf-9c4e-caababcf30a0\", \"num_ports\": 12, \"ports\": {\"1\": {\"uuid\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"mac_address\": \"ad:3c:77:44:98:27\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"2\": {\"uuid\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"mac_address\": \"fd:b1:68:f9:8f:eb\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"3\": {\"uuid\": \"8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1\", \"mac_address\": \"bb:ba:58:26:52:2d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"4\": {\"uuid\": \"3cde63c0-38e4-4faa-88ba-3a958118e2b3\", \"mac_address\": \"69:bc:6f:e1:30:32\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"5\": {\"uuid\": \"37e49743-1723-4b0e-a1e5-61d76e230c08\", \"mac_address\": \"d3:a0:8b:92:25:11\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"6\": {\"uuid\": \"3bf0c0c4-27f6-4a90-8279-1f713b46f4bf\", \"mac_address\": \"48:88:7c:71:0a:c0\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"7\": {\"uuid\": \"40b0ba34-9e70-448a-8fdf-836a5a71ed8f\", \"mac_address\": \"24:81:03:09:c0:be\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"8\": {\"uuid\": \"cd23d94b-84b8-441c-bd95-4e310682a095\", \"mac_address\": \"27:18:c5:47:fd:82\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"9\": {\"uuid\": \"608eb5bd-7875-4b64-a6f8-794e6283a305\", \"mac_address\": \"03:dd:34:d2:56:1c\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"10\": {\"uuid\": \"4acb48c6-74be-40d3-b706-64c06c55720b\", \"mac_address\": \"a3:55:83:af:b7:6b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"11\": {\"uuid\": \"73e989b5-3c2c-4035-8191-47220ea5ca43\", \"mac_address\": \"4f:60:84:21:50:6d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"12\": {\"uuid\": \"961ff733-a07c-433b-9433-8418a3761120\", \"mac_address\": \"7a:26:02:14:8d:da\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}}, \"mac_address_table\": {}}}, \"links\": {\"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\": {\"uuid\": \"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\", \"endpoint_a\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"endpoint_b\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\": {\"uuid\": \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\", \"endpoint_a\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"endpoint_b\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"15920e15-6cd1-4a93-b6af-acbcc6f6468e\", \"accounts\": {\"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\": {\"uuid\": \"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + ] + }, + "execution_count": 16, + "metadata": {}, + "output_type": "execute_result" + } + ], "source": [ "import json\n", "json.dumps(my_sim.describe_state())" From 4077eb3a5cfabbfe9800a2249a0a733e160a5a7c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 10:26:17 +0100 Subject: [PATCH 20/55] Add tests for network node adding/removal --- src/primaite/simulator/network/container.py | 8 +++- .../network/test_network_creation.py | 38 +++++++++++++++++++ .../_simulator/_network/test_container.py | 16 ++++++++ 3 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 tests/integration_tests/network/test_network_creation.py create mode 100644 tests/unit_tests/_primaite/_simulator/_network/test_container.py diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index be2a3bbb..5d7e6a47 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -52,7 +52,9 @@ class NetworkContainer(SimComponent): :type node: Node """ if node in self: - _LOGGER.warning(f"Can't add node {node}. It is already in the network.") + msg = f"Can't add node {node}. It is already in the network." + _LOGGER.warning(msg) + raise RuntimeWarning(msg) self.nodes[node.uuid] = node node.parent = self @@ -64,7 +66,9 @@ class NetworkContainer(SimComponent): :type node: Node """ if node not in self: - _LOGGER.warning(f"Can't remove node {node}. It's not in the network.") + msg = f"Can't remove node {node}. It's not in the network." + _LOGGER.warning(msg) + raise RuntimeWarning(msg) del self.nodes[node.uuid] del node.parent # misleading? diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py new file mode 100644 index 00000000..482c188d --- /dev/null +++ b/tests/integration_tests/network/test_network_creation.py @@ -0,0 +1,38 @@ +import pytest + +from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.hardware.base import Node + + +def test_adding_removing_nodes(): + """Check that we can create and add a node to a network.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + net.add_node(n1) + assert n1.parent is net + assert n1 in net + + net.remove_node(n1) + assert n1.parent is None + assert n1 not in net + + +def test_readding_node(): + """Check that warning is raised when readding a node.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + net.add_node(n1) + with pytest.raises(RuntimeWarning): + net.add_node(n1) + assert n1.parent is net + assert n1 in net + + +def test_removing_nonexistent_node(): + """Check that warning is raised when trying to remove a node that is not in the network.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + with pytest.raises(RuntimeWarning): + net.remove_node(n1) + assert n1.parent is None + assert n1 not in net diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py new file mode 100644 index 00000000..2492dc87 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -0,0 +1,16 @@ +import json + +from primaite.simulator.network.container import NetworkContainer + + +def test_creating_container(): + """Check that we can create a network container""" + net = NetworkContainer() + assert net.nodes and net.links + + +def test_describe_state(): + """Check that we can describe network state without raising errors, and that the result is JSON serialisable.""" + net = NetworkContainer() + state = net.describe_state() + json.dumps(state) # if this function call raises an error, the test fails, state was not JSON-serialisable From f38b423886e45dbf5422a1326f5402e453e99034 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 10:27:30 +0100 Subject: [PATCH 21/55] Update comment --- src/primaite/simulator/network/container.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 5d7e6a47..db782744 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -76,7 +76,7 @@ class NetworkContainer(SimComponent): """TODO.""" # I think we should not be forcing users to add and remove individual links. # Clearly if a link exists between two nodes in the network, then the link is also part of the network. - # I'm just not sure how we ought to handle link creation as it requires an unoccupied network device on the node + # I'm just not sure how we ought to handle link creation as it requires an unoccupied interface on the node. raise NotImplementedError def disconnect_nodes(self, node1: Node, node2: Node) -> None: From a818de8f0133fcd5c1eb37b2b12cb83dcb9b3c73 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 12:40:00 +0100 Subject: [PATCH 22/55] Add ability to connect nodes via the network. --- src/primaite/simulator/network/container.py | 48 ++++++++++++++----- .../simulator/network/hardware/base.py | 2 + .../network/test_network_creation.py | 46 +++++++++++++++++- 3 files changed, 84 insertions(+), 12 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index db782744..432356b8 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -1,8 +1,8 @@ -from typing import Any, Dict +from typing import Any, Dict, Union from primaite import getLogger from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent -from primaite.simulator.network.hardware.base import Link, Node +from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort _LOGGER = getLogger(__name__) @@ -72,16 +72,42 @@ class NetworkContainer(SimComponent): del self.nodes[node.uuid] del node.parent # misleading? - def connect_nodes(self, node1: Node, node2: Node) -> None: - """TODO.""" - # I think we should not be forcing users to add and remove individual links. - # Clearly if a link exists between two nodes in the network, then the link is also part of the network. - # I'm just not sure how we ought to handle link creation as it requires an unoccupied interface on the node. - raise NotImplementedError + def connect_nodes(self, endpoint_a: Union[NIC, SwitchPort], endpoint_b: Union[NIC, SwitchPort], **kwargs) -> None: + """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. - def disconnect_nodes(self, node1: Node, node2: Node) -> None: - """TODO.""" - raise NotImplementedError + :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_ + """ + node_a = endpoint_a.parent + node_b = endpoint_b.parent + msg = "" + if node_a not in self: + msg = f"Cannot create a link to {endpoint_a} because the node is not in the network." + if node_b not in self: + msg = f"Cannot create a link to {endpoint_b} because the node is not in the network." + if node_a is node_b: + msg = f"Cannot link {endpoint_a} to {endpoint_b} because they belong to the same node." + if msg: + _LOGGER.error(msg) + raise RuntimeError(msg) + + link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, **kwargs) + self.links[link.uuid] = link + link.parent = self + + def remove_link(self, link: Link) -> None: + """Disconnect a link from the network. + + :param link: The link to be removed + :type link: Link + """ + link.endpoint_a.disconnect_link() + link.endpoint_b.disconnect_link() + del self.links[link.uuid] + del link.parent def __contains__(self, item: Any) -> bool: if isinstance(item, Node): diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 28e7693a..5b49f008 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -918,6 +918,7 @@ class Node(SimComponent): if nic.uuid not in self.nics: self.nics[nic.uuid] = nic nic.connected_node = self + nic.parent = self self.sys_log.info(f"Connected NIC {nic}") if self.operating_state == NodeOperatingState.ON: nic.enable() @@ -938,6 +939,7 @@ class Node(SimComponent): nic = self.nics.get(nic) if nic or nic.uuid in self.nics: self.nics.pop(nic.uuid) + del nic.parent nic.disable() self.sys_log.info(f"Disconnected NIC {nic}") else: diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 482c188d..0ee827be 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -1,7 +1,7 @@ import pytest from primaite.simulator.network.container import NetworkContainer -from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.hardware.base import NIC, Node def test_adding_removing_nodes(): @@ -36,3 +36,47 @@ def test_removing_nonexistent_node(): net.remove_node(n1) assert n1.parent is None assert n1 not in net + + +def test_connecting_nodes(): + """Check that two nodes on the network can be connected.""" + net = NetworkContainer() + n1 = Node(hostname="computer") + n1_nic = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n1.connect_nic(n1_nic) + n2 = Node(hostname="server") + n2_nic = NIC(ip_address="120.30.0.2", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n2.connect_nic(n2_nic) + + net.add_node(n1) + net.add_node(n2) + + net.connect_nodes(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) + + assert len(net.links) == 1 + link = list(net.links.values())[0] + assert link in net + assert link.parent is net + + +def test_connecting_node_to_itself(): + net = NetworkContainer() + node = Node(hostname="computer") + nic1 = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") + node.connect_nic(nic1) + nic2 = NIC(ip_address="120.30.0.2", gateway="192.168.0.1", subnet_mask="255.255.255.0") + node.connect_nic(nic2) + + net.add_node(node) + + with pytest.raises(RuntimeError): + net.connect_nodes(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) + + assert node in net + assert nic1.connected_link is None + assert nic2.connected_link is None + assert len(net.links) == 0 + + +def test_disconnecting_nodes(): + ... From 7058c7e9a89e1462d924c0b578f92fb789a051b5 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 12:41:46 +0100 Subject: [PATCH 23/55] Rename networkcontainer to network --- src/primaite/simulator/network/container.py | 2 +- src/primaite/simulator/sim_container.py | 6 +++--- .../network/test_network_creation.py | 12 ++++++------ .../_primaite/_simulator/_network/test_container.py | 6 +++--- 4 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 432356b8..0612069c 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -7,7 +7,7 @@ from primaite.simulator.network.hardware.base import Link, NIC, Node, SwitchPort _LOGGER = getLogger(__name__) -class NetworkContainer(SimComponent): +class Network(SimComponent): """Top level container object representing the physical network.""" nodes: Dict[str, Node] = {} diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 50fe412c..319defe4 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -2,19 +2,19 @@ from typing import Dict from primaite.simulator.core import Action, ActionManager, AllowAllValidator, SimComponent from primaite.simulator.domain.controller import DomainController -from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.container import Network class Simulation(SimComponent): """Top-level simulation object which holds a reference to all other parts of the simulation.""" - network: NetworkContainer + network: Network domain: DomainController def __init__(self, **kwargs): """Initialise the Simulation.""" if not kwargs.get("network"): - kwargs["network"] = NetworkContainer() + kwargs["network"] = Network() if not kwargs.get("domain"): kwargs["domain"] = DomainController() diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 0ee827be..70b48806 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -1,12 +1,12 @@ import pytest -from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.container import Network from primaite.simulator.network.hardware.base import NIC, Node def test_adding_removing_nodes(): """Check that we can create and add a node to a network.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") net.add_node(n1) assert n1.parent is net @@ -19,7 +19,7 @@ def test_adding_removing_nodes(): def test_readding_node(): """Check that warning is raised when readding a node.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") net.add_node(n1) with pytest.raises(RuntimeWarning): @@ -30,7 +30,7 @@ def test_readding_node(): def test_removing_nonexistent_node(): """Check that warning is raised when trying to remove a node that is not in the network.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") with pytest.raises(RuntimeWarning): net.remove_node(n1) @@ -40,7 +40,7 @@ def test_removing_nonexistent_node(): def test_connecting_nodes(): """Check that two nodes on the network can be connected.""" - net = NetworkContainer() + net = Network() n1 = Node(hostname="computer") n1_nic = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") n1.connect_nic(n1_nic) @@ -60,7 +60,7 @@ def test_connecting_nodes(): def test_connecting_node_to_itself(): - net = NetworkContainer() + net = Network() node = Node(hostname="computer") nic1 = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") node.connect_nic(nic1) diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py index 2492dc87..5fc308cc 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/test_container.py +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -1,16 +1,16 @@ import json -from primaite.simulator.network.container import NetworkContainer +from primaite.simulator.network.container import Network def test_creating_container(): """Check that we can create a network container""" - net = NetworkContainer() + net = Network() assert net.nodes and net.links def test_describe_state(): """Check that we can describe network state without raising errors, and that the result is JSON serialisable.""" - net = NetworkContainer() + net = Network() state = net.describe_state() json.dumps(state) # if this function call raises an error, the test fails, state was not JSON-serialisable From 78008e3c6e80199bd5116455e8afb1f2ccecf15b Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 12:52:38 +0100 Subject: [PATCH 24/55] Fix container test --- .../unit_tests/_primaite/_simulator/_network/test_container.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/unit_tests/_primaite/_simulator/_network/test_container.py b/tests/unit_tests/_primaite/_simulator/_network/test_container.py index 5fc308cc..290e7cc3 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/test_container.py +++ b/tests/unit_tests/_primaite/_simulator/_network/test_container.py @@ -6,7 +6,8 @@ from primaite.simulator.network.container import Network def test_creating_container(): """Check that we can create a network container""" net = Network() - assert net.nodes and net.links + assert net.nodes == {} + assert net.links == {} def test_describe_state(): From fec44aef53e25b2ac1a83e851f92a5c212a5daef Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 13:03:16 +0100 Subject: [PATCH 25/55] Rename connect_nodes to connect and fix minor bug --- src/primaite/simulator/network/container.py | 2 +- src/primaite/simulator/network/hardware/base.py | 1 + tests/integration_tests/network/test_network_creation.py | 4 ++-- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 0612069c..1c03358c 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -72,7 +72,7 @@ class Network(SimComponent): del self.nodes[node.uuid] del node.parent # misleading? - def connect_nodes(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 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 diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index 5b49f008..fe3b5b15 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1011,6 +1011,7 @@ class Switch(Node): 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): diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 70b48806..418f5e5f 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -51,7 +51,7 @@ def test_connecting_nodes(): net.add_node(n1) net.add_node(n2) - net.connect_nodes(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) + net.connect(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) assert len(net.links) == 1 link = list(net.links.values())[0] @@ -70,7 +70,7 @@ def test_connecting_node_to_itself(): net.add_node(node) with pytest.raises(RuntimeError): - net.connect_nodes(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) + net.connect(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) assert node in net assert nic1.connected_link is None From 05bb0f295b25973a0776d0ab4b7e65c6767ce93f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 24 Aug 2023 13:06:45 +0100 Subject: [PATCH 26/55] Update notebook tutorial on creating a simulation --- .../notebooks/create-simulation.ipynb | 123 +++++++++--------- 1 file changed, 60 insertions(+), 63 deletions(-) diff --git a/src/primaite/notebooks/create-simulation.ipynb b/src/primaite/notebooks/create-simulation.ipynb index e3e7dfb7..baf7bd2c 100644 --- a/src/primaite/notebooks/create-simulation.ipynb +++ b/src/primaite/notebooks/create-simulation.ipynb @@ -42,11 +42,11 @@ { "data": { "text/plain": [ - "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", - " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", + "{'uuid': '2ef348c6-32e5-4c5c-83b7-3b82d0b6123b',\n", + " 'network': {'uuid': 'dd2d1a02-d461-4505-8bbd-fd0681750175',\n", " 'nodes': {},\n", " 'links': {}},\n", - " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e', 'accounts': {}}}" + " 'domain': {'uuid': 'ae0423ee-51fa-41e7-be80-c642b39707f6', 'accounts': {}}}" ] }, "execution_count": 2, @@ -113,10 +113,10 @@ "name": "stderr", "output_type": "stream", "text": [ - "2023-08-23 15:44:02,059: NIC 1b:8f:94:4f:46:99/130.1.1.1 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", - "2023-08-23 15:44:02,062: SwitchPort ad:3c:77:44:98:27 connected to Link 1b:8f:94:4f:46:99/130.1.1.1<-->ad:3c:77:44:98:27\n", - "2023-08-23 15:44:02,064: NIC 50:f4:6b:9b:a8:74/130.1.1.2 connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n", - "2023-08-23 15:44:02,065: SwitchPort fd:b1:68:f9:8f:eb connected to Link 50:f4:6b:9b:a8:74/130.1.1.2<-->fd:b1:68:f9:8f:eb\n" + "2023-08-24 13:06:28,617: NIC cc:be:ec:43:a6:4c/130.1.1.1 connected to Link cc:be:ec:43:a6:4c/130.1.1.1<-->79:2b:4a:70:c3:50\n", + "2023-08-24 13:06:28,618: SwitchPort 79:2b:4a:70:c3:50 connected to Link cc:be:ec:43:a6:4c/130.1.1.1<-->79:2b:4a:70:c3:50\n", + "2023-08-24 13:06:28,619: NIC c2:1e:48:e1:a4:ad/130.1.1.2 connected to Link c2:1e:48:e1:a4:ad/130.1.1.2<-->1a:2d:12:38:80:2f\n", + "2023-08-24 13:06:28,620: SwitchPort 1a:2d:12:38:80:2f connected to Link c2:1e:48:e1:a4:ad/130.1.1.2<-->1a:2d:12:38:80:2f\n" ] } ], @@ -132,11 +132,8 @@ "my_server.connect_nic(server_nic)\n", "\n", "\n", - "pc_to_switch = Link(endpoint_a=pc_nic, endpoint_b=my_swtich.switch_ports[1])\n", - "server_to_swtich = Link(endpoint_a=server_nic, endpoint_b=my_swtich.switch_ports[2])\n", - "\n", - "my_sim.network.links[pc_to_switch.uuid] = pc_to_switch\n", - "my_sim.network.links[server_to_swtich.uuid] = server_to_swtich" + "net.connect(pc_nic, my_swtich.switch_ports[1])\n", + "net.connect(server_nic, my_swtich.switch_ports[2])\n" ] }, { @@ -174,7 +171,7 @@ { "data": { "text/plain": [ - "FileSystemFile(uuid='f45bffd7-4aa1-4f6f-81ba-85e746abd28b', name='favicon.ico', size=40.0, file_type=, action_manager=None)" + "FileSystemFile(uuid='7d56a563-ecc0-4011-8c97-240dd6c885c0', name='favicon.ico', size=40.0, file_type=, action_manager=None)" ] }, "execution_count": 9, @@ -269,31 +266,31 @@ { "data": { "text/plain": [ - "{'uuid': '95929b6a-1ce4-4c94-966c-6d3246d7caf9',\n", - " 'network': {'uuid': '4b41398e-d768-47c5-80cf-4278cfc35a24',\n", - " 'nodes': {'1599c08e-a101-41a7-a86a-4176660c4270': {'uuid': '1599c08e-a101-41a7-a86a-4176660c4270',\n", + "{'uuid': '2ef348c6-32e5-4c5c-83b7-3b82d0b6123b',\n", + " 'network': {'uuid': 'dd2d1a02-d461-4505-8bbd-fd0681750175',\n", + " 'nodes': {'2f03b32b-7290-4921-8670-faebe4a19d63': {'uuid': '2f03b32b-7290-4921-8670-faebe4a19d63',\n", " 'hostname': 'primaite_pc',\n", " 'operating_state': 0,\n", - " 'NICs': {'ab09d298-ac44-40ef-b950-b4ca6268d482': {'uuid': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", + " 'NICs': {'e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b': {'uuid': 'e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b',\n", " 'ip_adress': '130.1.1.1',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': '1b:8f:94:4f:46:99',\n", + " 'mac_address': 'cc:be:ec:43:a6:4c',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': '92120387-14cb-426c-98f2-64d64a85f560',\n", - " 'folders': {'6a11bd03-bc59-4da9-8474-639fcb72b9be': {'uuid': '6a11bd03-bc59-4da9-8474-639fcb72b9be',\n", + " 'file_system': {'uuid': '0b7206af-3e0a-41b0-8115-ae9e0dbbcd81',\n", + " 'folders': {'c161bc7c-9abd-4666-9b49-2745fdb65ebe': {'uuid': 'c161bc7c-9abd-4666-9b49-2745fdb65ebe',\n", " 'name': 'downloads',\n", " 'size': 1000.0,\n", - " 'files': {'194b2029-4723-4cff-b6d7-e647e4fb687d': {'uuid': '194b2029-4723-4cff-b6d7-e647e4fb687d',\n", + " 'files': {'f807d777-d167-4f37-9f9b-ced634af6ed5': {'uuid': 'f807d777-d167-4f37-9f9b-ced634af6ed5',\n", " 'name': 'firefox_installer.zip',\n", " 'size': 1000.0,\n", " 'file_type': 'ZIP'}},\n", " 'is_quarantined': False}}},\n", - " 'applications': {'ae49273b-f581-44e7-ae8c-18cc766158e8': {'uuid': 'ae49273b-f581-44e7-ae8c-18cc766158e8',\n", + " 'applications': {'ea466b2f-1ed5-49fd-9579-44852bff684d': {'uuid': 'ea466b2f-1ed5-49fd-9579-44852bff684d',\n", " 'health_state': 'GOOD',\n", " 'health_state_red_view': 'GOOD',\n", " 'criticality': 'MEDIUM',\n", @@ -311,29 +308,29 @@ " 'groups': []}},\n", " 'services': {},\n", " 'process': {}},\n", - " '7231c745-e186-47a2-8f69-006033b38b8f': {'uuid': '7231c745-e186-47a2-8f69-006033b38b8f',\n", + " 'e9afc0bc-fb21-48a3-9868-2ede6a3181dc': {'uuid': 'e9afc0bc-fb21-48a3-9868-2ede6a3181dc',\n", " 'hostname': 'google_server',\n", " 'operating_state': 0,\n", - " 'NICs': {'d138788b-2a8e-4c5c-aa5d-b5c28758a78a': {'uuid': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", + " 'NICs': {'956ce240-8fb3-4fde-8635-ac4ea601a582': {'uuid': '956ce240-8fb3-4fde-8635-ac4ea601a582',\n", " 'ip_adress': '130.1.1.2',\n", " 'subnet_mask': '255.255.255.0',\n", " 'gateway': '130.1.1.255',\n", - " 'mac_address': '50:f4:6b:9b:a8:74',\n", + " 'mac_address': 'c2:1e:48:e1:a4:ad',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'wake_on_lan': False,\n", " 'dns_servers': [],\n", " 'enabled': False}},\n", - " 'file_system': {'uuid': '85f1d50c-ded7-4160-9a11-1305ab25934b',\n", - " 'folders': {'86c2666e-31da-46a9-a267-4dc87e2620f9': {'uuid': '86c2666e-31da-46a9-a267-4dc87e2620f9',\n", + " 'file_system': {'uuid': 'c3f99c30-b493-4fb6-b13e-d2005d851b59',\n", + " 'folders': {'869eda49-21f2-4fc1-8681-78725cdd5c70': {'uuid': '869eda49-21f2-4fc1-8681-78725cdd5c70',\n", " 'name': 'static',\n", " 'size': 0,\n", " 'files': {},\n", " 'is_quarantined': False},\n", - " '1a4479df-6f52-428c-b7b9-c026ab24d2a3': {'uuid': '1a4479df-6f52-428c-b7b9-c026ab24d2a3',\n", + " '9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e': {'uuid': '9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e',\n", " 'name': 'root',\n", " 'size': 40.0,\n", - " 'files': {'f45bffd7-4aa1-4f6f-81ba-85e746abd28b': {'uuid': 'f45bffd7-4aa1-4f6f-81ba-85e746abd28b',\n", + " 'files': {'7d56a563-ecc0-4011-8c97-240dd6c885c0': {'uuid': '7d56a563-ecc0-4011-8c97-240dd6c885c0',\n", " 'name': 'favicon.ico',\n", " 'size': 40.0,\n", " 'file_type': 'PNG'}},\n", @@ -341,81 +338,81 @@ " 'applications': {},\n", " 'services': {},\n", " 'process': {}},\n", - " '384bab1c-aa23-49cf-9c4e-caababcf30a0': {'uuid': '384bab1c-aa23-49cf-9c4e-caababcf30a0',\n", + " '47814452-ef47-4e6b-9087-796c438d4698': {'uuid': '47814452-ef47-4e6b-9087-796c438d4698',\n", " 'num_ports': 12,\n", - " 'ports': {1: {'uuid': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", - " 'mac_address': 'ad:3c:77:44:98:27',\n", + " 'ports': {1: {'uuid': 'b76fe86f-bb92-4346-8e83-217a2fb0bc67',\n", + " 'mac_address': '79:2b:4a:70:c3:50',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 2: {'uuid': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", - " 'mac_address': 'fd:b1:68:f9:8f:eb',\n", + " 2: {'uuid': '6f8fc6e7-76a4-441a-b7af-441edbdcc6ac',\n", + " 'mac_address': '1a:2d:12:38:80:2f',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 3: {'uuid': '8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1',\n", - " 'mac_address': 'bb:ba:58:26:52:2d',\n", + " 3: {'uuid': '1aa75a3c-01f1-4293-9894-5396fa412690',\n", + " 'mac_address': 'd1:7b:36:c1:82:c1',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 4: {'uuid': '3cde63c0-38e4-4faa-88ba-3a958118e2b3',\n", - " 'mac_address': '69:bc:6f:e1:30:32',\n", + " 4: {'uuid': 'fe6c9f44-59d5-403e-973a-6f19fce7b9b9',\n", + " 'mac_address': 'e3:6b:cc:0c:98:9b',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 5: {'uuid': '37e49743-1723-4b0e-a1e5-61d76e230c08',\n", - " 'mac_address': 'd3:a0:8b:92:25:11',\n", + " 5: {'uuid': 'e9e83e37-8537-4884-98a6-87017540078f',\n", + " 'mac_address': '32:09:c0:4a:f1:20',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 6: {'uuid': '3bf0c0c4-27f6-4a90-8279-1f713b46f4bf',\n", - " 'mac_address': '48:88:7c:71:0a:c0',\n", + " 6: {'uuid': '747f2cd3-8902-4da8-8829-b0b53fe79735',\n", + " 'mac_address': 'e8:20:0b:04:b8:76',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 7: {'uuid': '40b0ba34-9e70-448a-8fdf-836a5a71ed8f',\n", - " 'mac_address': '24:81:03:09:c0:be',\n", + " 7: {'uuid': '88ed129e-0ddb-4d29-ba3c-58d81efe240e',\n", + " 'mac_address': '7f:b4:f4:2e:b6:71',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 8: {'uuid': 'cd23d94b-84b8-441c-bd95-4e310682a095',\n", - " 'mac_address': '27:18:c5:47:fd:82',\n", + " 8: {'uuid': '6c1a4c3c-25d8-46f6-98a8-54073d0ca0d3',\n", + " 'mac_address': 'f6:22:2d:24:b9:71',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 9: {'uuid': '608eb5bd-7875-4b64-a6f8-794e6283a305',\n", - " 'mac_address': '03:dd:34:d2:56:1c',\n", + " 9: {'uuid': 'b2bfc006-6a6b-4701-a75a-27954592d429',\n", + " 'mac_address': 'b6:a5:92:a5:aa:1b',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 10: {'uuid': '4acb48c6-74be-40d3-b706-64c06c55720b',\n", - " 'mac_address': 'a3:55:83:af:b7:6b',\n", + " 10: {'uuid': '3c607386-87a2-4d0b-ac04-449416ca5b1f',\n", + " 'mac_address': 'b3:75:7d:ce:88:0a',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 11: {'uuid': '73e989b5-3c2c-4035-8191-47220ea5ca43',\n", - " 'mac_address': '4f:60:84:21:50:6d',\n", + " 11: {'uuid': '590002c8-27fa-4c31-b17b-7b89dbf8cdf8',\n", + " 'mac_address': 'c0:25:a6:64:52:8e',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False},\n", - " 12: {'uuid': '961ff733-a07c-433b-9433-8418a3761120',\n", - " 'mac_address': '7a:26:02:14:8d:da',\n", + " 12: {'uuid': 'b7e25eed-547a-4c17-8cb9-8b976ce4bbd9',\n", + " 'mac_address': '98:50:96:47:ca:bc',\n", " 'speed': 100,\n", " 'mtu': 1500,\n", " 'enabled': False}},\n", " 'mac_address_table': {}}},\n", - " 'links': {'67df55f4-c485-4eed-a4dc-fe6f96f6b2f3': {'uuid': '67df55f4-c485-4eed-a4dc-fe6f96f6b2f3',\n", - " 'endpoint_a': 'ab09d298-ac44-40ef-b950-b4ca6268d482',\n", - " 'endpoint_b': 'e64847dd-6f19-4f5e-b473-4f9098ca4b9c',\n", + " 'links': {'a51a4435-20ae-43cf-a151-26e824968b3d': {'uuid': 'a51a4435-20ae-43cf-a151-26e824968b3d',\n", + " 'endpoint_a': 'e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b',\n", + " 'endpoint_b': 'b76fe86f-bb92-4346-8e83-217a2fb0bc67',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0},\n", - " '4fdb61da-7cc9-43ea-9ee6-7d9853deff72': {'uuid': '4fdb61da-7cc9-43ea-9ee6-7d9853deff72',\n", - " 'endpoint_a': 'd138788b-2a8e-4c5c-aa5d-b5c28758a78a',\n", - " 'endpoint_b': 'd65f815d-dc28-4313-a4f0-b918bb026e7c',\n", + " 'ae3486e5-f78e-4092-96d1-d7e8176f2b7d': {'uuid': 'ae3486e5-f78e-4092-96d1-d7e8176f2b7d',\n", + " 'endpoint_a': '956ce240-8fb3-4fde-8635-ac4ea601a582',\n", + " 'endpoint_b': '6f8fc6e7-76a4-441a-b7af-441edbdcc6ac',\n", " 'bandwidth': 100.0,\n", " 'current_load': 0.0}}},\n", - " 'domain': {'uuid': '15920e15-6cd1-4a93-b6af-acbcc6f6468e',\n", - " 'accounts': {'d7f5bd32-5071-4bec-a111-a9f4e1aca45a': {'uuid': 'd7f5bd32-5071-4bec-a111-a9f4e1aca45a',\n", + " 'domain': {'uuid': 'ae0423ee-51fa-41e7-be80-c642b39707f6',\n", + " 'accounts': {'917eda28-9a67-4449-bddd-87e2141a3162': {'uuid': '917eda28-9a67-4449-bddd-87e2141a3162',\n", " 'num_logons': 0,\n", " 'num_logoffs': 0,\n", " 'num_group_changes': 0,\n", @@ -442,7 +439,7 @@ { "data": { "text/plain": [ - "'{\"uuid\": \"95929b6a-1ce4-4c94-966c-6d3246d7caf9\", \"network\": {\"uuid\": \"4b41398e-d768-47c5-80cf-4278cfc35a24\", \"nodes\": {\"1599c08e-a101-41a7-a86a-4176660c4270\": {\"uuid\": \"1599c08e-a101-41a7-a86a-4176660c4270\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"ab09d298-ac44-40ef-b950-b4ca6268d482\": {\"uuid\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"1b:8f:94:4f:46:99\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"92120387-14cb-426c-98f2-64d64a85f560\", \"folders\": {\"6a11bd03-bc59-4da9-8474-639fcb72b9be\": {\"uuid\": \"6a11bd03-bc59-4da9-8474-639fcb72b9be\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"194b2029-4723-4cff-b6d7-e647e4fb687d\": {\"uuid\": \"194b2029-4723-4cff-b6d7-e647e4fb687d\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"ae49273b-f581-44e7-ae8c-18cc766158e8\": {\"uuid\": \"ae49273b-f581-44e7-ae8c-18cc766158e8\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"7231c745-e186-47a2-8f69-006033b38b8f\": {\"uuid\": \"7231c745-e186-47a2-8f69-006033b38b8f\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\": {\"uuid\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"50:f4:6b:9b:a8:74\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"85f1d50c-ded7-4160-9a11-1305ab25934b\", \"folders\": {\"86c2666e-31da-46a9-a267-4dc87e2620f9\": {\"uuid\": \"86c2666e-31da-46a9-a267-4dc87e2620f9\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\": {\"uuid\": \"1a4479df-6f52-428c-b7b9-c026ab24d2a3\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\": {\"uuid\": \"f45bffd7-4aa1-4f6f-81ba-85e746abd28b\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}, \"384bab1c-aa23-49cf-9c4e-caababcf30a0\": {\"uuid\": \"384bab1c-aa23-49cf-9c4e-caababcf30a0\", \"num_ports\": 12, \"ports\": {\"1\": {\"uuid\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"mac_address\": \"ad:3c:77:44:98:27\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"2\": {\"uuid\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"mac_address\": \"fd:b1:68:f9:8f:eb\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"3\": {\"uuid\": \"8e1d8783-80af-4aad-bc1e-0c5f1d28b9a1\", \"mac_address\": \"bb:ba:58:26:52:2d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"4\": {\"uuid\": \"3cde63c0-38e4-4faa-88ba-3a958118e2b3\", \"mac_address\": \"69:bc:6f:e1:30:32\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"5\": {\"uuid\": \"37e49743-1723-4b0e-a1e5-61d76e230c08\", \"mac_address\": \"d3:a0:8b:92:25:11\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"6\": {\"uuid\": \"3bf0c0c4-27f6-4a90-8279-1f713b46f4bf\", \"mac_address\": \"48:88:7c:71:0a:c0\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"7\": {\"uuid\": \"40b0ba34-9e70-448a-8fdf-836a5a71ed8f\", \"mac_address\": \"24:81:03:09:c0:be\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"8\": {\"uuid\": \"cd23d94b-84b8-441c-bd95-4e310682a095\", \"mac_address\": \"27:18:c5:47:fd:82\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"9\": {\"uuid\": \"608eb5bd-7875-4b64-a6f8-794e6283a305\", \"mac_address\": \"03:dd:34:d2:56:1c\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"10\": {\"uuid\": \"4acb48c6-74be-40d3-b706-64c06c55720b\", \"mac_address\": \"a3:55:83:af:b7:6b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"11\": {\"uuid\": \"73e989b5-3c2c-4035-8191-47220ea5ca43\", \"mac_address\": \"4f:60:84:21:50:6d\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"12\": {\"uuid\": \"961ff733-a07c-433b-9433-8418a3761120\", \"mac_address\": \"7a:26:02:14:8d:da\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}}, \"mac_address_table\": {}}}, \"links\": {\"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\": {\"uuid\": \"67df55f4-c485-4eed-a4dc-fe6f96f6b2f3\", \"endpoint_a\": \"ab09d298-ac44-40ef-b950-b4ca6268d482\", \"endpoint_b\": \"e64847dd-6f19-4f5e-b473-4f9098ca4b9c\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\": {\"uuid\": \"4fdb61da-7cc9-43ea-9ee6-7d9853deff72\", \"endpoint_a\": \"d138788b-2a8e-4c5c-aa5d-b5c28758a78a\", \"endpoint_b\": \"d65f815d-dc28-4313-a4f0-b918bb026e7c\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"15920e15-6cd1-4a93-b6af-acbcc6f6468e\", \"accounts\": {\"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\": {\"uuid\": \"d7f5bd32-5071-4bec-a111-a9f4e1aca45a\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" + "'{\"uuid\": \"2ef348c6-32e5-4c5c-83b7-3b82d0b6123b\", \"network\": {\"uuid\": \"dd2d1a02-d461-4505-8bbd-fd0681750175\", \"nodes\": {\"2f03b32b-7290-4921-8670-faebe4a19d63\": {\"uuid\": \"2f03b32b-7290-4921-8670-faebe4a19d63\", \"hostname\": \"primaite_pc\", \"operating_state\": 0, \"NICs\": {\"e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b\": {\"uuid\": \"e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b\", \"ip_adress\": \"130.1.1.1\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"cc:be:ec:43:a6:4c\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"0b7206af-3e0a-41b0-8115-ae9e0dbbcd81\", \"folders\": {\"c161bc7c-9abd-4666-9b49-2745fdb65ebe\": {\"uuid\": \"c161bc7c-9abd-4666-9b49-2745fdb65ebe\", \"name\": \"downloads\", \"size\": 1000.0, \"files\": {\"f807d777-d167-4f37-9f9b-ced634af6ed5\": {\"uuid\": \"f807d777-d167-4f37-9f9b-ced634af6ed5\", \"name\": \"firefox_installer.zip\", \"size\": 1000.0, \"file_type\": \"ZIP\"}}, \"is_quarantined\": false}}}, \"applications\": {\"ea466b2f-1ed5-49fd-9579-44852bff684d\": {\"uuid\": \"ea466b2f-1ed5-49fd-9579-44852bff684d\", \"health_state\": \"GOOD\", \"health_state_red_view\": \"GOOD\", \"criticality\": \"MEDIUM\", \"patching_count\": 0, \"scanning_count\": 0, \"revealed_to_red\": false, \"installing_count\": 0, \"max_sessions\": 1, \"tcp\": true, \"udp\": true, \"ports\": [\"HTTP\"], \"opearting_state\": \"RUNNING\", \"execution_control_status\": \"manual\", \"num_executions\": 0, \"groups\": []}}, \"services\": {}, \"process\": {}}, \"e9afc0bc-fb21-48a3-9868-2ede6a3181dc\": {\"uuid\": \"e9afc0bc-fb21-48a3-9868-2ede6a3181dc\", \"hostname\": \"google_server\", \"operating_state\": 0, \"NICs\": {\"956ce240-8fb3-4fde-8635-ac4ea601a582\": {\"uuid\": \"956ce240-8fb3-4fde-8635-ac4ea601a582\", \"ip_adress\": \"130.1.1.2\", \"subnet_mask\": \"255.255.255.0\", \"gateway\": \"130.1.1.255\", \"mac_address\": \"c2:1e:48:e1:a4:ad\", \"speed\": 100, \"mtu\": 1500, \"wake_on_lan\": false, \"dns_servers\": [], \"enabled\": false}}, \"file_system\": {\"uuid\": \"c3f99c30-b493-4fb6-b13e-d2005d851b59\", \"folders\": {\"869eda49-21f2-4fc1-8681-78725cdd5c70\": {\"uuid\": \"869eda49-21f2-4fc1-8681-78725cdd5c70\", \"name\": \"static\", \"size\": 0, \"files\": {}, \"is_quarantined\": false}, \"9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e\": {\"uuid\": \"9fbe0e41-0d6a-4142-9c73-9c0de2dbde6e\", \"name\": \"root\", \"size\": 40.0, \"files\": {\"7d56a563-ecc0-4011-8c97-240dd6c885c0\": {\"uuid\": \"7d56a563-ecc0-4011-8c97-240dd6c885c0\", \"name\": \"favicon.ico\", \"size\": 40.0, \"file_type\": \"PNG\"}}, \"is_quarantined\": false}}}, \"applications\": {}, \"services\": {}, \"process\": {}}, \"47814452-ef47-4e6b-9087-796c438d4698\": {\"uuid\": \"47814452-ef47-4e6b-9087-796c438d4698\", \"num_ports\": 12, \"ports\": {\"1\": {\"uuid\": \"b76fe86f-bb92-4346-8e83-217a2fb0bc67\", \"mac_address\": \"79:2b:4a:70:c3:50\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"2\": {\"uuid\": \"6f8fc6e7-76a4-441a-b7af-441edbdcc6ac\", \"mac_address\": \"1a:2d:12:38:80:2f\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"3\": {\"uuid\": \"1aa75a3c-01f1-4293-9894-5396fa412690\", \"mac_address\": \"d1:7b:36:c1:82:c1\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"4\": {\"uuid\": \"fe6c9f44-59d5-403e-973a-6f19fce7b9b9\", \"mac_address\": \"e3:6b:cc:0c:98:9b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"5\": {\"uuid\": \"e9e83e37-8537-4884-98a6-87017540078f\", \"mac_address\": \"32:09:c0:4a:f1:20\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"6\": {\"uuid\": \"747f2cd3-8902-4da8-8829-b0b53fe79735\", \"mac_address\": \"e8:20:0b:04:b8:76\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"7\": {\"uuid\": \"88ed129e-0ddb-4d29-ba3c-58d81efe240e\", \"mac_address\": \"7f:b4:f4:2e:b6:71\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"8\": {\"uuid\": \"6c1a4c3c-25d8-46f6-98a8-54073d0ca0d3\", \"mac_address\": \"f6:22:2d:24:b9:71\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"9\": {\"uuid\": \"b2bfc006-6a6b-4701-a75a-27954592d429\", \"mac_address\": \"b6:a5:92:a5:aa:1b\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"10\": {\"uuid\": \"3c607386-87a2-4d0b-ac04-449416ca5b1f\", \"mac_address\": \"b3:75:7d:ce:88:0a\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"11\": {\"uuid\": \"590002c8-27fa-4c31-b17b-7b89dbf8cdf8\", \"mac_address\": \"c0:25:a6:64:52:8e\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}, \"12\": {\"uuid\": \"b7e25eed-547a-4c17-8cb9-8b976ce4bbd9\", \"mac_address\": \"98:50:96:47:ca:bc\", \"speed\": 100, \"mtu\": 1500, \"enabled\": false}}, \"mac_address_table\": {}}}, \"links\": {\"a51a4435-20ae-43cf-a151-26e824968b3d\": {\"uuid\": \"a51a4435-20ae-43cf-a151-26e824968b3d\", \"endpoint_a\": \"e07e2a7f-b09f-4bd8-8e92-cffbf1f2270b\", \"endpoint_b\": \"b76fe86f-bb92-4346-8e83-217a2fb0bc67\", \"bandwidth\": 100.0, \"current_load\": 0.0}, \"ae3486e5-f78e-4092-96d1-d7e8176f2b7d\": {\"uuid\": \"ae3486e5-f78e-4092-96d1-d7e8176f2b7d\", \"endpoint_a\": \"956ce240-8fb3-4fde-8635-ac4ea601a582\", \"endpoint_b\": \"6f8fc6e7-76a4-441a-b7af-441edbdcc6ac\", \"bandwidth\": 100.0, \"current_load\": 0.0}}}, \"domain\": {\"uuid\": \"ae0423ee-51fa-41e7-be80-c642b39707f6\", \"accounts\": {\"917eda28-9a67-4449-bddd-87e2141a3162\": {\"uuid\": \"917eda28-9a67-4449-bddd-87e2141a3162\", \"num_logons\": 0, \"num_logoffs\": 0, \"num_group_changes\": 0, \"username\": \"admin\", \"password\": \"admin12\", \"account_type\": \"USER\", \"enabled\": true}}}}'" ] }, "execution_count": 16, From c6f71600fc39c2b58bee837b6ed99db0fbcfec18 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 25 Aug 2023 09:07:32 +0100 Subject: [PATCH 27/55] #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 a9e969aa13cc5d824356a451f32fb7ae9d8d4de6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 25 Aug 2023 15:29:53 +0100 Subject: [PATCH 28/55] init work on database --- .../file_system/file_system_file_type.py | 8 ++++ .../simulator/system/services/database.py | 41 +++++++++++++++++++ .../simulator/system/services/service.py | 12 ++++++ 3 files changed, 61 insertions(+) create mode 100644 src/primaite/simulator/system/services/database.py diff --git a/src/primaite/simulator/file_system/file_system_file_type.py b/src/primaite/simulator/file_system/file_system_file_type.py index 7e2d8706..88aeb430 100644 --- a/src/primaite/simulator/file_system/file_system_file_type.py +++ b/src/primaite/simulator/file_system/file_system_file_type.py @@ -87,6 +87,14 @@ class FileSystemFileType(str, Enum): GZ = 31 "Gzip compressed file." + # Database file types + MDF = 32 + "MS SQL Server primary database file" + NDF = 33 + "MS SQL Server secondary database file" + LDF = 34 + "MS SQL Server transaction log" + file_type_sizes_KB = { FileSystemFileType.UNKNOWN: 0, diff --git a/src/primaite/simulator/system/services/database.py b/src/primaite/simulator/system/services/database.py new file mode 100644 index 00000000..29e3f242 --- /dev/null +++ b/src/primaite/simulator/system/services/database.py @@ -0,0 +1,41 @@ +from primaite.simulator.file_system.file_system_file_type import FileSystemFileType +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.system.services.service import Service + + +class DatabaseService(Service): + """TODO.""" + + def __init__(self, parent_node: Node, **kwargs): + super().__init__(**kwargs) + self._setup_files_on_node() + + def _setup_files_on_node( + self, + db_size: int = 1000, + use_secondary_db_file: bool = False, + secondary_db_size: int = 300, + folder_name: str = "database", + ): + """Set up files that are required by the database on the parent host. + + :param db_size: Initial file size of the main database file, defaults to 1000 + :type db_size: int, optional + :param use_secondary_db_file: Whether to use a secondary database file, defaults to False + :type use_secondary_db_file: bool, optional + :param secondary_db_size: Size of the secondary db file, defaults to None + :type secondary_db_size: int, optional + :param folder_name: Name of the folder which will be setup to hold the db files, defaults to "database" + :type folder_name: str, optional + """ + folder = self.parent.file_system.create_folder(folder_name) + self.parent.file_system.create_file("db_primary_store", db_size, FileSystemFileType.MDF, folder=folder) + self.parent.file_system.create_file("db_transaction_log", "1", FileSystemFileType.LDF, folder=folder) + if use_secondary_db_file: + self.parent.file_system.create_file( + "db_secondary_store", secondary_db_size, FileSystemFileType.NDF, folder=folder + ) + + # todo next: + # create session? (maybe not) + # add actions for setting service state to compromised? (probably definitely) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index eafff3f0..ed2aa23b 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -2,6 +2,7 @@ from abc import abstractmethod from enum import Enum from typing import Any, Dict, List +from primaite.simulator.network.hardware.base import Node from primaite.simulator.system.software import IOSoftware @@ -32,6 +33,17 @@ class Service(IOSoftware): operating_state: ServiceOperatingState "The current operating state of the Service." + @abstractmethod + def __init__(self, parent_node: Node, **kwargs): + """Create the service on a node. + + :param parent_node: The node on which this service runs. + :type parent_node: Node + """ + super().__init__(**kwargs) + self.parent: Node = parent_node + self.parent.software_manager.add_service(self) + @abstractmethod def describe_state(self) -> Dict: """ From ae6e835955136c815eb496efa2bcb2f4329b0af6 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 25 Aug 2023 15:58:07 +0100 Subject: [PATCH 29/55] Apply suggestions from code review. --- src/primaite/simulator/network/container.py | 35 +++++++++---------- .../simulator/network/hardware/base.py | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 1c03358c..85676034 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -52,11 +52,11 @@ class Network(SimComponent): :type node: Node """ if node in self: - msg = f"Can't add node {node}. It is already in the network." - _LOGGER.warning(msg) - raise RuntimeWarning(msg) + _LOGGER.warning(f"Can't add node {node.uuid}. It is already in the network.") + return self.nodes[node.uuid] = node node.parent = self + _LOGGER.info(f"Added node {node.uuid} to Network {self.uuid}") def remove_node(self, node: Node) -> None: """ @@ -66,11 +66,11 @@ class Network(SimComponent): :type node: Node """ if node not in self: - msg = f"Can't remove node {node}. It's not in the network." - _LOGGER.warning(msg) - raise RuntimeWarning(msg) - del self.nodes[node.uuid] - del node.parent # misleading? + _LOGGER.warning(f"Can't remove node {node.uuid}. It's not in the network.") + return + self.nodes.pop(node.uuid) + 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: """Connect two nodes on the network by creating a link between an NIC/SwitchPort of each one. @@ -83,20 +83,18 @@ class Network(SimComponent): """ node_a = endpoint_a.parent node_b = endpoint_b.parent - msg = "" if node_a not in self: - msg = f"Cannot create a link to {endpoint_a} because the node is not in the network." + self.add_node(node_a) if node_b not in self: - msg = f"Cannot create a link to {endpoint_b} because the node is not in the network." + self.add_node(node_b) if node_a is node_b: - msg = f"Cannot link {endpoint_a} to {endpoint_b} because they belong to the same node." - if msg: - _LOGGER.error(msg) - raise RuntimeError(msg) + _LOGGER.warn(f"Cannot link endpoint {endpoint_a} to {endpoint_b} because they belong to the same node.") + return link = Link(endpoint_a=endpoint_a, endpoint_b=endpoint_b, **kwargs) self.links[link.uuid] = link link.parent = self + _LOGGER.info(f"Added link {link.uuid} to connect {endpoint_a} and {endpoint_b}") def remove_link(self, link: Link) -> None: """Disconnect a link from the network. @@ -106,12 +104,13 @@ class Network(SimComponent): """ link.endpoint_a.disconnect_link() link.endpoint_b.disconnect_link() - del self.links[link.uuid] - del link.parent + self.links.pop(link.uuid) + link.parent = None + _LOGGER.info(f"Removed link {link.uuid} from network {self.uuid}.") def __contains__(self, item: Any) -> bool: if isinstance(item, Node): return item.uuid in self.nodes elif isinstance(item, Link): return item.uuid in self.links - raise TypeError("") + return False diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index fe3b5b15..9acdf0b4 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -939,7 +939,7 @@ class Node(SimComponent): nic = self.nics.get(nic) if nic or nic.uuid in self.nics: self.nics.pop(nic.uuid) - del nic.parent + nic.parent = None nic.disable() self.sys_log.info(f"Disconnected NIC {nic}") else: From 6e602aa1514b92dc34ec7324d96bdd58fc72efdb Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Fri, 25 Aug 2023 17:56:05 +0100 Subject: [PATCH 30/55] Fix unit tests by removing warning checks --- src/primaite/simulator/core.py | 8 ++--- .../network/test_network_creation.py | 29 ++++++++++++++----- 2 files changed, 26 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 63120ecf..b7dfcf72 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,6 +1,6 @@ """Core of the PrimAITE Simulator.""" from abc import ABC, abstractmethod -from typing import Callable, Dict, List, Optional +from typing import Callable, Dict, List, Optional, Union from uuid import uuid4 from pydantic import BaseModel, ConfigDict, Extra @@ -199,9 +199,9 @@ class SimComponent(BaseModel): return self._parent @parent.setter - def parent(self, new_parent: "SimComponent") -> None: - if self._parent: - msg = f"Overwriting parent of {self}, {self._parent} with {new_parent}" + def parent(self, new_parent: Union["SimComponent", None]) -> None: + if self._parent and new_parent: + msg = f"Overwriting parent of {self.uuid}. Old parent: {self._parent.uuid}, New parent: {new_parent.uuid}" _LOGGER.warn(msg) raise RuntimeWarning(msg) self._parent = new_parent diff --git a/tests/integration_tests/network/test_network_creation.py b/tests/integration_tests/network/test_network_creation.py index 418f5e5f..356eb1db 100644 --- a/tests/integration_tests/network/test_network_creation.py +++ b/tests/integration_tests/network/test_network_creation.py @@ -22,8 +22,7 @@ def test_readding_node(): net = Network() n1 = Node(hostname="computer") net.add_node(n1) - with pytest.raises(RuntimeWarning): - net.add_node(n1) + net.add_node(n1) assert n1.parent is net assert n1 in net @@ -32,8 +31,7 @@ def test_removing_nonexistent_node(): """Check that warning is raised when trying to remove a node that is not in the network.""" net = Network() n1 = Node(hostname="computer") - with pytest.raises(RuntimeWarning): - net.remove_node(n1) + net.remove_node(n1) assert n1.parent is None assert n1 not in net @@ -69,8 +67,7 @@ def test_connecting_node_to_itself(): net.add_node(node) - with pytest.raises(RuntimeError): - net.connect(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) + net.connect(node.nics[nic1.uuid], node.nics[nic2.uuid], bandwidth=30) assert node in net assert nic1.connected_link is None @@ -79,4 +76,22 @@ def test_connecting_node_to_itself(): def test_disconnecting_nodes(): - ... + net = Network() + + n1 = Node(hostname="computer") + n1_nic = NIC(ip_address="120.30.0.1", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n1.connect_nic(n1_nic) + net.add_node(n1) + + n2 = Node(hostname="server") + n2_nic = NIC(ip_address="120.30.0.2", gateway="192.168.0.1", subnet_mask="255.255.255.0") + n2.connect_nic(n2_nic) + net.add_node(n2) + + net.connect(n1.nics[n1_nic.uuid], n2.nics[n2_nic.uuid], bandwidth=30) + assert len(net.links) == 1 + + link = list(net.links.values())[0] + net.remove_link(link) + assert link not in net + assert len(net.links) == 0 From 319e87d200e81da1b33437c5f545f395ae063028 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Mon, 28 Aug 2023 22:34:20 +0100 Subject: [PATCH 31/55] Make changes to the way actions work --- src/primaite/simulator/core.py | 24 ++++++++++++- src/primaite/simulator/domain/controller.py | 24 +++++++------ src/primaite/simulator/network/container.py | 21 +++++++----- src/primaite/simulator/sim_container.py | 34 ++++++++++--------- .../system/applications/application.py | 10 +----- .../simulator/system/services/database.py | 2 ++ .../simulator/system/services/service.py | 12 ------- src/primaite/simulator/system/software.py | 26 +++++++------- 8 files changed, 83 insertions(+), 70 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 63120ecf..7d8999e8 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -136,7 +136,7 @@ class SimComponent(BaseModel): if not kwargs.get("uuid"): kwargs["uuid"] = str(uuid4()) super().__init__(**kwargs) - self.action_manager: Optional[ActionManager] = None + self._action_manager: ActionManager = self._init_action_manager() self._parent: Optional["SimComponent"] = None @abstractmethod @@ -153,6 +153,28 @@ class SimComponent(BaseModel): } return state + def _init_action_manager(self) -> ActionManager: + """ + Initialise the action manager for this component. + + When using a hierarchy of components, the child classes should call the parent class's _init_action_manager and + add additional actions on top of the existing generic ones. + + Example usage for inherited classes: + + ..code::python + + class WebBrowser(Application): + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() # all actions generic to any Application get initialised + am.add_action(...) # initialise any actions specific to the web browser + return am + + :return: Actiona manager object belonging to this sim component. + :rtype: ActionManager + """ + return ActionManager() + def apply_action(self, action: List[str], context: Dict = {}) -> None: """ Apply an action to a simulation component. Action data is passed in as a 'namespaced' list of strings. diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index f772ab22..961ef550 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -85,17 +85,6 @@ class DomainController(SimComponent): def __init__(self, **kwargs): super().__init__(**kwargs) - self.action_manager = ActionManager() - # Action 'account' matches requests like: - # ['account', '', *account_action] - self.action_manager.add_action( - "account", - Action( - func=lambda request, context: self.accounts[request.pop(0)].apply_action(request, context), - validator=GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), - ), - ) - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -109,6 +98,19 @@ class DomainController(SimComponent): state.update({"accounts": {uuid: acct.describe_state() for uuid, acct in self.accounts.items()}}) return state + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + # Action 'account' matches requests like: + # ['account', '', *account_action] + am.add_action( + "account", + Action( + func=lambda request, context: self.accounts[request.pop(0)].apply_action(request, context), + validator=GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), + ), + ) + return am + def _register_account(self, account: Account) -> None: """TODO.""" ... diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 1c03358c..d04da987 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -17,15 +17,6 @@ class Network(SimComponent): """Initialise the network.""" super().__init__(**kwargs) - self.action_manager = ActionManager() - self.action_manager.add_action( - "node", - Action( - func=lambda request, context: self.nodes[request.pop(0)].apply_action(request, context), - validator=AllowAllValidator(), - ), - ) - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -44,6 +35,18 @@ class Network(SimComponent): ) return state + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + + am.add_action( + "node", + Action( + func=lambda request, context: self.nodes[request.pop(0)].apply_action(request, context), + validator=AllowAllValidator(), + ), + ) + return am + def add_node(self, node: Node) -> None: """ Add an existing node to the network. diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 319defe4..8f676e6f 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -21,22 +21,6 @@ class Simulation(SimComponent): super().__init__(**kwargs) - self.action_manager = ActionManager() - # pass through network actions to the network objects - self.action_manager.add_action( - "network", - Action( - func=lambda request, context: self.network.apply_action(request, context), validator=AllowAllValidator() - ), - ) - # pass through domain actions to the domain object - self.action_manager.add_action( - "domain", - Action( - func=lambda request, context: self.domain.apply_action(request, context), validator=AllowAllValidator() - ), - ) - def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -54,3 +38,21 @@ class Simulation(SimComponent): } ) return state + + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + # pass through network actions to the network objects + am.add_action( + "network", + Action( + func=lambda request, context: self.network.apply_action(request, context), validator=AllowAllValidator() + ), + ) + # pass through domain actions to the domain object + am.add_action( + "domain", + Action( + func=lambda request, context: self.domain.apply_action(request, context), validator=AllowAllValidator() + ), + ) + return am diff --git a/src/primaite/simulator/system/applications/application.py b/src/primaite/simulator/system/applications/application.py index 37748560..6a07f00f 100644 --- a/src/primaite/simulator/system/applications/application.py +++ b/src/primaite/simulator/system/applications/application.py @@ -1,6 +1,6 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict, List, Set +from typing import Any, Dict, Set from primaite.simulator.system.software import IOSoftware @@ -53,14 +53,6 @@ class Application(IOSoftware): ) return state - def apply_action(self, action: List[str]) -> None: - """ - Applies a list of actions to the Application. - - :param action: A list of actions to apply. - """ - pass - def reset_component_for_episode(self, episode: int): """ Resets the Application component for a new episode. diff --git a/src/primaite/simulator/system/services/database.py b/src/primaite/simulator/system/services/database.py index 29e3f242..720967e7 100644 --- a/src/primaite/simulator/system/services/database.py +++ b/src/primaite/simulator/system/services/database.py @@ -28,6 +28,8 @@ class DatabaseService(Service): :param folder_name: Name of the folder which will be setup to hold the db files, defaults to "database" :type folder_name: str, optional """ + # note that this parent.file_system.create_folder call in the future will be authenticated by using permissions + # handler. This permission will be granted based on service account given to the database service. folder = self.parent.file_system.create_folder(folder_name) self.parent.file_system.create_file("db_primary_store", db_size, FileSystemFileType.MDF, folder=folder) self.parent.file_system.create_file("db_transaction_log", "1", FileSystemFileType.LDF, folder=folder) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index ed2aa23b..eafff3f0 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -2,7 +2,6 @@ from abc import abstractmethod from enum import Enum from typing import Any, Dict, List -from primaite.simulator.network.hardware.base import Node from primaite.simulator.system.software import IOSoftware @@ -33,17 +32,6 @@ class Service(IOSoftware): operating_state: ServiceOperatingState "The current operating state of the Service." - @abstractmethod - def __init__(self, parent_node: Node, **kwargs): - """Create the service on a node. - - :param parent_node: The node on which this service runs. - :type parent_node: Node - """ - super().__init__(**kwargs) - self.parent: Node = parent_node - self.parent.software_manager.add_service(self) - @abstractmethod def describe_state(self) -> Dict: """ diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index a2acd9fb..8e931cad 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,6 +1,6 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict, List, Set +from typing import Any, Dict, Set from primaite.simulator.core import SimComponent from primaite.simulator.network.transmission.transport_layer import Port @@ -98,17 +98,6 @@ class Software(SimComponent): ) return state - def apply_action(self, action: List[str]) -> None: - """ - Applies a list of actions to the software. - - The specifics of how these actions are applied should be implemented in subclasses. - - :param action: A list of actions to apply. - :type action: List[str] - """ - pass - def reset_component_for_episode(self, episode: int): """ Resets the software component for a new episode. @@ -119,6 +108,19 @@ class Software(SimComponent): """ pass + def set_health_state(self, health_state: SoftwareHealthState) -> None: + """ + Assign a new health state to this software. + + Note: this should only be possible when the software is currently running, but the software base class has no + operating state, only subclasses do. So subclasses will need to implement this check. TODO: check if this should + be changed so that the base Software class has a running attr. + + :param health_state: New health state to assign to the software + :type health_state: SoftwareHealthState + """ + self.health_state_actual = health_state + class IOSoftware(Software): """ From 1eff41c7861cb6aa53fee877b44762e56c16826f Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 29 Aug 2023 11:10:29 +0100 Subject: [PATCH 32/55] Update docs based on new action options --- docs/source/simulation_structure.rst | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/docs/source/simulation_structure.rst b/docs/source/simulation_structure.rst index 7630ae0f..f3ef866c 100644 --- a/docs/source/simulation_structure.rst +++ b/docs/source/simulation_structure.rst @@ -49,16 +49,14 @@ snippet demonstrates usage of the ``ActionPermissionValidator``. name: str apps = [] - def __init__(self, **kwargs): - super().__init__(**kwargs) - self.action_manager = ActionManager() - - self.action_manager.add_action( + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + am.add_action( "reset_factory_settings", Action( func = lambda request, context: self.reset_factory_settings(), validator = GroupMembershipValidator([AccountGroup.DOMAIN_ADMIN]), - ), + ) ) def reset_factory_settings(self): From 7b61322e704b06da9ef797162ba0021df25ed116 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 29 Aug 2023 12:34:41 +0100 Subject: [PATCH 33/55] Add service actions --- src/primaite/simulator/core.py | 7 +- .../simulator/system/services/service.py | 79 +++++++++++++++++-- src/primaite/simulator/system/software.py | 17 +++- 3 files changed, 92 insertions(+), 11 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 7d8999e8..90abb675 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -41,7 +41,9 @@ class Action: the action can be performed or not. """ - def __init__(self, func: Callable[[List[str], Dict], None], validator: ActionPermissionValidator) -> None: + def __init__( + self, func: Callable[[List[str], Dict], None], validator: ActionPermissionValidator = AllowAllValidator() + ) -> None: """ Save the functions that are for this action. @@ -58,7 +60,8 @@ class Action: :param func: Function that performs the request. :type func: Callable[[List[str], Dict], None] - :param validator: Function that checks if the request is authenticated given the context. + :param validator: Function that checks if the request is authenticated given the context. By default, if no + validator is provided, an 'allow all' validator is added which permits all requests. :type validator: ActionPermissionValidator """ self.func: Callable[[List[str], Dict], None] = func diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index eafff3f0..1a36589f 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,7 +1,8 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict, List +from typing import Any, Dict +from primaite.simulator.core import Action, ActionManager from primaite.simulator.system.software import IOSoftware @@ -46,13 +47,16 @@ class Service(IOSoftware): state.update({"operating_state": self.operating_state.name}) return state - def apply_action(self, action: List[str]) -> None: - """ - Applies a list of actions to the Service. - - :param action: A list of actions to apply. - """ - pass + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + am.add_action("stop", Action(func=lambda request, context: self.stop())) + am.add_action("start", Action(func=lambda request, context: self.start())) + am.add_action("pause", Action(func=lambda request, context: self.pause())) + am.add_action("resume", Action(func=lambda request, context: self.resume())) + am.add_action("restart", Action(func=lambda request, context: self.restart())) + am.add_action("disable", Action(func=lambda request, context: self.disable())) + am.add_action("enable", Action(func=lambda request, context: self.enable())) + return am def reset_component_for_episode(self, episode: int): """ @@ -86,3 +90,62 @@ class Service(IOSoftware): :return: True if successful, False otherwise. """ pass + + # TODO: validate this state transition model. + # Possibly state transition could be defined more succinctly than a separate function with lots of if statements. + + def stop(self) -> None: + """Stop the service.""" + if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: + self.operating_state = ServiceOperatingState.STOPPED + + def start(self) -> None: + """Start the service.""" + if self.operating_state == ServiceOperatingState.STOPPED: + self.operating_state = ServiceOperatingState.RUNNING + + def pause(self) -> None: + """Pause the service.""" + if self.operating_state == ServiceOperatingState.RUNNING: + self.operating_state = ServiceOperatingState.PAUSED + + def resume(self) -> None: + """Resume paused service.""" + if self.operating_state == ServiceOperatingState.PAUSED: + self.operating_state = ServiceOperatingState.RUNNING + + def restart(self) -> None: + """Restart running service.""" + if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: + self.operating_state = ServiceOperatingState.RESTARTING + self.restart_countdown = 5 # TODO: implement restart duration + + def disable(self) -> None: + """Disable the service.""" + if self.operating_state in [ + ServiceOperatingState.RUNNING, + ServiceOperatingState.STOPPED, + ServiceOperatingState.PAUSED, + ]: + self.operating_state = ServiceOperatingState.DISABLED + + def enable(self) -> None: + """Enable the disabled service.""" + if self.operating_state == ServiceOperatingState.DISABLED: + self.operating_state = ServiceOperatingState.STOPPED + + def apply_timestep(self, timestep: int) -> None: + """ + Apply a single timestep of simulation dynamics to this service. + + In this instance, if any multi-timestep processes are currently occurring (such as restarting or installation), + then they are brought one step closer to being finished. + + :param timestep: The current timestep number. (Amount of time since simulation episode began) + :type timestep: int + """ + super().apply_timestep(timestep) + if self.operating_state == ServiceOperatingState.RESTARTING: + self.restart_countdown -= 1 + if self.restart_countdown <= 0: + self.operating_state = ServiceOperatingState.RUNNING diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 8e931cad..8db0b0c4 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -2,7 +2,7 @@ from abc import abstractmethod from enum import Enum from typing import Any, Dict, Set -from primaite.simulator.core import SimComponent +from primaite.simulator.core import Action, ActionManager, SimComponent from primaite.simulator.network.transmission.transport_layer import Port @@ -98,6 +98,17 @@ class Software(SimComponent): ) return state + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + am.add_action( + "compromise", + Action( + func=lambda request, context: self.set_health_state(SoftwareHealthState.COMPROMISED), + ), + ) + am.add_action("scan", Action(func=lambda request, context: self.scan())) + return am + def reset_component_for_episode(self, episode: int): """ Resets the software component for a new episode. @@ -121,6 +132,10 @@ class Software(SimComponent): """ self.health_state_actual = health_state + def scan(self) -> None: + """Update the observed health status to match the actual health status.""" + self.health_state_visible = self.health_state_actual + class IOSoftware(Software): """ From 94325d1fde9d8da023836ed437252f03cdaae889 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 29 Aug 2023 13:21:34 +0100 Subject: [PATCH 34/55] Add Install method to software. --- .../simulator/network/hardware/base.py | 35 ++++++++++++++++++- .../simulator/system/services/database.py | 21 ++++++----- src/primaite/simulator/system/software.py | 19 +++++++++- tests/integration_tests/system/__init__.py | 0 .../system/test_database_on_node.py | 22 ++++++++++++ .../_primaite/_simulator/_system/__init__.py | 0 .../_simulator/_system/_services/__init__.py | 0 .../_system/_services/test_database.py | 17 +++++++++ 8 files changed, 104 insertions(+), 10 deletions(-) create mode 100644 tests/integration_tests/system/__init__.py create mode 100644 tests/integration_tests/system/test_database_on_node.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_services/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index fe3b5b15..41e16936 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -4,7 +4,7 @@ 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 @@ -994,6 +994,39 @@ class Node(SimComponent): elif frame.ip.protocol == IPProtocol.ICMP: self.icmp.process_icmp(frame=frame) + def install_service(self, service: Service) -> None: + """ + Install a service on this node. + + :param service: Service instance that has not been installed on any node yet. + :type service: Service + """ + if service in self: + _LOGGER.warning(f"Can't add service {service.uuid} to node {self.uuid}. It's already installed.") + return + service.parent = self + service.install() # Perform any additional setup, such as creating files for this service on the node. + _LOGGER.info(f"Added service {service.uuid} to node {self.uuid}") + + def uninstall_service(self, service: Service) -> None: + """Uninstall and completely remove service from this node. + + :param service: Service object that is currently associated with this node. + :type service: Service + """ + if service not in self: + _LOGGER.warning(f"Can't remove service {service.uuid} from node {self.uuid}. It's not installed.") + return + service.uninstall() # Perform additional teardown, such as removing files or restarting the machine. + self.services.pop(service.uuid) + service.parent = None + _LOGGER.info(f"Removed service {service.uuid} from node {self.uuid}") + + def __contains__(self, item: Any) -> bool: + if isinstance(item, Service): + return item.uuid in self.services + return None + class Switch(Node): """A class representing a Layer 2 network switch.""" diff --git a/src/primaite/simulator/system/services/database.py b/src/primaite/simulator/system/services/database.py index 720967e7..0d1de15c 100644 --- a/src/primaite/simulator/system/services/database.py +++ b/src/primaite/simulator/system/services/database.py @@ -1,3 +1,5 @@ +from typing import Dict + from primaite.simulator.file_system.file_system_file_type import FileSystemFileType from primaite.simulator.network.hardware.base import Node from primaite.simulator.system.services.service import Service @@ -6,11 +8,17 @@ from primaite.simulator.system.services.service import Service class DatabaseService(Service): """TODO.""" - def __init__(self, parent_node: Node, **kwargs): - super().__init__(**kwargs) - self._setup_files_on_node() + def describe_state(self) -> Dict: + """TODO.""" + return super().describe_state() - def _setup_files_on_node( + def install(self) -> None: + """Perform first time install on a node, creating necessary files.""" + super().install() + assert isinstance(self.parent, Node), "Database install can only happen after the db service is added to a node" + self._setup_files() + + def _setup_files( self, db_size: int = 1000, use_secondary_db_file: bool = False, @@ -30,6 +38,7 @@ class DatabaseService(Service): """ # note that this parent.file_system.create_folder call in the future will be authenticated by using permissions # handler. This permission will be granted based on service account given to the database service. + self.parent: Node folder = self.parent.file_system.create_folder(folder_name) self.parent.file_system.create_file("db_primary_store", db_size, FileSystemFileType.MDF, folder=folder) self.parent.file_system.create_file("db_transaction_log", "1", FileSystemFileType.LDF, folder=folder) @@ -37,7 +46,3 @@ class DatabaseService(Service): self.parent.file_system.create_file( "db_secondary_store", secondary_db_size, FileSystemFileType.NDF, folder=folder ) - - # todo next: - # create session? (maybe not) - # add actions for setting service state to compromised? (probably definitely) diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 8db0b0c4..17eaee3d 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,10 +1,13 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict, Set +from typing import Any, Dict, Set, TYPE_CHECKING from primaite.simulator.core import Action, ActionManager, SimComponent from primaite.simulator.network.transmission.transport_layer import Port +if TYPE_CHECKING: + from primaite.simulator.network.hardware.base import Node + class SoftwareType(Enum): """ @@ -132,6 +135,20 @@ class Software(SimComponent): """ self.health_state_actual = health_state + @abstractmethod + def install(self) -> None: + """ + Perform first-time setup of this service on a node. + + This is an abstract class that should be overwritten by specific applications or services. It must be called + after the service is already associate with a node. For example, a service may need to authenticate with a + server during installation, or create files in the node's filesystem. + + :param node: Node on which this software runs. + :type node: Node + """ + parent: "Node" = self.parent # noqa + def scan(self) -> None: """Update the observed health status to match the actual health status.""" self.health_state_visible = self.health_state_actual diff --git a/tests/integration_tests/system/__init__.py b/tests/integration_tests/system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration_tests/system/test_database_on_node.py b/tests/integration_tests/system/test_database_on_node.py new file mode 100644 index 00000000..f295eaf1 --- /dev/null +++ b/tests/integration_tests/system/test_database_on_node.py @@ -0,0 +1,22 @@ +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.database import DatabaseService +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.software import SoftwareCriticality, SoftwareHealthState + + +def test_installing_database(): + db = DatabaseService( + name="SQL-database", + health_state_actual=SoftwareHealthState.GOOD, + health_state_visible=SoftwareHealthState.GOOD, + criticality=SoftwareCriticality.MEDIUM, + ports=[ + Port.SQL_SERVER, + ], + operating_state=ServiceOperatingState.RUNNING, + ) + + node = Node(hostname="db-server") + + node.install_service(db) diff --git a/tests/unit_tests/_primaite/_simulator/_system/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py new file mode 100644 index 00000000..ea5c1b83 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py @@ -0,0 +1,17 @@ +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.database import DatabaseService +from primaite.simulator.system.services.service import ServiceOperatingState +from primaite.simulator.system.software import SoftwareCriticality, SoftwareHealthState + + +def test_creation(): + db = DatabaseService( + name="SQL-database", + health_state_actual=SoftwareHealthState.GOOD, + health_state_visible=SoftwareHealthState.GOOD, + criticality=SoftwareCriticality.MEDIUM, + ports=[ + Port.SQL_SERVER, + ], + operating_state=ServiceOperatingState.RUNNING, + ) From f0b82cbdfba6070217eee4011c9c14aaab5f6f38 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 29 Aug 2023 14:15:49 +0100 Subject: [PATCH 35/55] Add ability to uninstall service --- .../simulator/file_system/file_system.py | 4 +-- .../simulator/network/hardware/base.py | 1 + .../simulator/system/services/database.py | 31 ++++++++++++++--- src/primaite/simulator/system/software.py | 19 ++++++----- .../system/test_database_on_node.py | 34 +++++++++++++++++++ 5 files changed, 74 insertions(+), 15 deletions(-) diff --git a/src/primaite/simulator/file_system/file_system.py b/src/primaite/simulator/file_system/file_system.py index 440b7dc5..1346d3e0 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 +from typing import Dict, Optional, Union from primaite import getLogger from primaite.simulator.core import SimComponent @@ -211,7 +211,7 @@ class FileSystem(SimComponent): if file is not None: return file - def get_folder_by_name(self, folder_name: str) -> FileSystemFolder: + def get_folder_by_name(self, folder_name: str) -> Union[FileSystemFolder, None]: """ Returns a the first folder with a matching name. diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index a68ff480..e3e38f86 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1004,6 +1004,7 @@ class Node(SimComponent): if service in self: _LOGGER.warning(f"Can't add service {service.uuid} to node {self.uuid}. It's already installed.") return + self.services[service.uuid] = service service.parent = self service.install() # Perform any additional setup, such as creating files for this service on the node. _LOGGER.info(f"Added service {service.uuid} to node {self.uuid}") diff --git a/src/primaite/simulator/system/services/database.py b/src/primaite/simulator/system/services/database.py index 0d1de15c..554455b8 100644 --- a/src/primaite/simulator/system/services/database.py +++ b/src/primaite/simulator/system/services/database.py @@ -12,6 +12,21 @@ class DatabaseService(Service): """TODO.""" return super().describe_state() + def uninstall(self) -> None: + """ + Undo installation procedure. + + This method deletes files created when installing the database, and the database folder if it is empty. + """ + super().uninstall() + node: Node = self.parent + node.file_system.delete_file(self.primary_store) + node.file_system.delete_file(self.transaction_log) + if self.secondary_store: + node.file_system.delete_file(self.secondary_store) + if len(self.folder.files) == 0: + node.file_system.delete_folder(self.folder) + def install(self) -> None: """Perform first time install on a node, creating necessary files.""" super().install() @@ -39,10 +54,16 @@ class DatabaseService(Service): # note that this parent.file_system.create_folder call in the future will be authenticated by using permissions # handler. This permission will be granted based on service account given to the database service. self.parent: Node - folder = self.parent.file_system.create_folder(folder_name) - self.parent.file_system.create_file("db_primary_store", db_size, FileSystemFileType.MDF, folder=folder) - self.parent.file_system.create_file("db_transaction_log", "1", FileSystemFileType.LDF, folder=folder) + self.folder = self.parent.file_system.create_folder(folder_name) + self.primary_store = self.parent.file_system.create_file( + "db_primary_store", db_size, FileSystemFileType.MDF, folder=self.folder + ) + self.transaction_log = self.parent.file_system.create_file( + "db_transaction_log", "1", FileSystemFileType.LDF, folder=self.folder + ) if use_secondary_db_file: - self.parent.file_system.create_file( - "db_secondary_store", secondary_db_size, FileSystemFileType.NDF, folder=folder + self.secondary_store = self.parent.file_system.create_file( + "db_secondary_store", secondary_db_size, FileSystemFileType.NDF, folder=self.folder ) + else: + self.secondary_store = None diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 17eaee3d..1fcdb522 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,13 +1,10 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict, Set, TYPE_CHECKING +from typing import Any, Dict, Set from primaite.simulator.core import Action, ActionManager, SimComponent from primaite.simulator.network.transmission.transport_layer import Port -if TYPE_CHECKING: - from primaite.simulator.network.hardware.base import Node - class SoftwareType(Enum): """ @@ -143,11 +140,17 @@ class Software(SimComponent): This is an abstract class that should be overwritten by specific applications or services. It must be called after the service is already associate with a node. For example, a service may need to authenticate with a server during installation, or create files in the node's filesystem. - - :param node: Node on which this software runs. - :type node: Node """ - parent: "Node" = self.parent # noqa + pass + + def uninstall(self) -> None: + """Uninstall this service from a node. + + This is an abstract class that should be overwritten by applications or services. It must be called after the + `install` method has already been run on that node. It should undo any installation steps, for example by + deleting files, or contacting a server. + """ + pass def scan(self) -> None: """Update the observed health status to match the actual health status.""" diff --git a/tests/integration_tests/system/test_database_on_node.py b/tests/integration_tests/system/test_database_on_node.py index f295eaf1..73d19339 100644 --- a/tests/integration_tests/system/test_database_on_node.py +++ b/tests/integration_tests/system/test_database_on_node.py @@ -20,3 +20,37 @@ def test_installing_database(): node = Node(hostname="db-server") node.install_service(db) + + assert db in node + + file_exists = False + for folder in node.file_system.folders.values(): + for file in folder.files.values(): + if file.name == "db_primary_store": + file_exists = True + break + if file_exists: + break + assert file_exists + + +def test_uninstalling_database(): + db = DatabaseService( + name="SQL-database", + health_state_actual=SoftwareHealthState.GOOD, + health_state_visible=SoftwareHealthState.GOOD, + criticality=SoftwareCriticality.MEDIUM, + ports=[ + Port.SQL_SERVER, + ], + operating_state=ServiceOperatingState.RUNNING, + ) + + node = Node(hostname="db-server") + + node.install_service(db) + + node.uninstall_service(db) + + assert db not in node + assert node.file_system.get_folder_by_name("database") is None From 40d3e04e648d442696636152288c52dde200f389 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Tue, 29 Aug 2023 14:33:28 +0100 Subject: [PATCH 36/55] Move init action manager function to the top --- src/primaite/simulator/core.py | 28 +++++++-------- src/primaite/simulator/domain/controller.py | 26 +++++++------- src/primaite/simulator/network/container.py | 24 ++++++------- src/primaite/simulator/sim_container.py | 36 +++++++++---------- .../simulator/system/services/service.py | 22 ++++++------ src/primaite/simulator/system/software.py | 22 ++++++------ 6 files changed, 79 insertions(+), 79 deletions(-) diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index c12b1ad5..69edd8db 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -142,20 +142,6 @@ class SimComponent(BaseModel): self._action_manager: ActionManager = self._init_action_manager() self._parent: Optional["SimComponent"] = None - @abstractmethod - def describe_state(self) -> Dict: - """ - Return a dictionary describing the state of this object and any objects managed by it. - - This is similar to pydantic ``model_dump()``, but it only outputs information about the objects owned by this - object. If there are objects referenced by this object that are owned by something else, it is not included in - this output. - """ - state = { - "uuid": self.uuid, - } - return state - def _init_action_manager(self) -> ActionManager: """ Initialise the action manager for this component. @@ -178,6 +164,20 @@ class SimComponent(BaseModel): """ return ActionManager() + @abstractmethod + def describe_state(self) -> Dict: + """ + Return a dictionary describing the state of this object and any objects managed by it. + + This is similar to pydantic ``model_dump()``, but it only outputs information about the objects owned by this + object. If there are objects referenced by this object that are owned by something else, it is not included in + this output. + """ + state = { + "uuid": self.uuid, + } + return state + def apply_action(self, action: List[str], context: Dict = {}) -> None: """ Apply an action to a simulation component. Action data is passed in as a 'namespaced' list of strings. diff --git a/src/primaite/simulator/domain/controller.py b/src/primaite/simulator/domain/controller.py index 961ef550..b436ca79 100644 --- a/src/primaite/simulator/domain/controller.py +++ b/src/primaite/simulator/domain/controller.py @@ -85,19 +85,6 @@ class DomainController(SimComponent): def __init__(self, **kwargs): super().__init__(**kwargs) - 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 - """ - state = super().describe_state() - state.update({"accounts": {uuid: acct.describe_state() for uuid, acct in self.accounts.items()}}) - return state - def _init_action_manager(self) -> ActionManager: am = super()._init_action_manager() # Action 'account' matches requests like: @@ -111,6 +98,19 @@ class DomainController(SimComponent): ) return am + 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 + """ + state = super().describe_state() + state.update({"accounts": {uuid: acct.describe_state() for uuid, acct in self.accounts.items()}}) + return state + def _register_account(self, account: Account) -> None: """TODO.""" ... diff --git a/src/primaite/simulator/network/container.py b/src/primaite/simulator/network/container.py index 95eaeb0c..e0226e6c 100644 --- a/src/primaite/simulator/network/container.py +++ b/src/primaite/simulator/network/container.py @@ -17,6 +17,18 @@ class Network(SimComponent): """Initialise the network.""" super().__init__(**kwargs) + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + + am.add_action( + "node", + Action( + func=lambda request, context: self.nodes[request.pop(0)].apply_action(request, context), + validator=AllowAllValidator(), + ), + ) + return am + def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. @@ -35,18 +47,6 @@ class Network(SimComponent): ) return state - def _init_action_manager(self) -> ActionManager: - am = super()._init_action_manager() - - am.add_action( - "node", - Action( - func=lambda request, context: self.nodes[request.pop(0)].apply_action(request, context), - validator=AllowAllValidator(), - ), - ) - return am - def add_node(self, node: Node) -> None: """ Add an existing node to the network. diff --git a/src/primaite/simulator/sim_container.py b/src/primaite/simulator/sim_container.py index 8f676e6f..2a5123f3 100644 --- a/src/primaite/simulator/sim_container.py +++ b/src/primaite/simulator/sim_container.py @@ -21,24 +21,6 @@ class Simulation(SimComponent): super().__init__(**kwargs) - 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 - """ - state = super().describe_state() - state.update( - { - "network": self.network.describe_state(), - "domain": self.domain.describe_state(), - } - ) - return state - def _init_action_manager(self) -> ActionManager: am = super()._init_action_manager() # pass through network actions to the network objects @@ -56,3 +38,21 @@ class Simulation(SimComponent): ), ) return am + + 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 + """ + state = super().describe_state() + state.update( + { + "network": self.network.describe_state(), + "domain": self.domain.describe_state(), + } + ) + return state diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 1a36589f..7e67d05f 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -33,6 +33,17 @@ class Service(IOSoftware): operating_state: ServiceOperatingState "The current operating state of the Service." + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + am.add_action("stop", Action(func=lambda request, context: self.stop())) + am.add_action("start", Action(func=lambda request, context: self.start())) + am.add_action("pause", Action(func=lambda request, context: self.pause())) + am.add_action("resume", Action(func=lambda request, context: self.resume())) + am.add_action("restart", Action(func=lambda request, context: self.restart())) + am.add_action("disable", Action(func=lambda request, context: self.disable())) + am.add_action("enable", Action(func=lambda request, context: self.enable())) + return am + @abstractmethod def describe_state(self) -> Dict: """ @@ -47,17 +58,6 @@ class Service(IOSoftware): state.update({"operating_state": self.operating_state.name}) return state - def _init_action_manager(self) -> ActionManager: - am = super()._init_action_manager() - am.add_action("stop", Action(func=lambda request, context: self.stop())) - am.add_action("start", Action(func=lambda request, context: self.start())) - am.add_action("pause", Action(func=lambda request, context: self.pause())) - am.add_action("resume", Action(func=lambda request, context: self.resume())) - am.add_action("restart", Action(func=lambda request, context: self.restart())) - am.add_action("disable", Action(func=lambda request, context: self.disable())) - am.add_action("enable", Action(func=lambda request, context: self.enable())) - return am - def reset_component_for_episode(self, episode: int): """ Resets the Service component for a new episode. diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 1fcdb522..605a062b 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -75,6 +75,17 @@ class Software(SimComponent): revealed_to_red: bool = False "Indicates if the software has been revealed to red agent, defaults is False." + def _init_action_manager(self) -> ActionManager: + am = super()._init_action_manager() + am.add_action( + "compromise", + Action( + func=lambda request, context: self.set_health_state(SoftwareHealthState.COMPROMISED), + ), + ) + am.add_action("scan", Action(func=lambda request, context: self.scan())) + return am + @abstractmethod def describe_state(self) -> Dict: """ @@ -98,17 +109,6 @@ class Software(SimComponent): ) return state - def _init_action_manager(self) -> ActionManager: - am = super()._init_action_manager() - am.add_action( - "compromise", - Action( - func=lambda request, context: self.set_health_state(SoftwareHealthState.COMPROMISED), - ), - ) - am.add_action("scan", Action(func=lambda request, context: self.scan())) - return am - def reset_component_for_episode(self, episode: int): """ Resets the software component for a new episode. From 1bf51c7741f1c45dd6846d9bb3ee611aadd02bf1 Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Wed, 30 Aug 2023 21:38:55 +0100 Subject: [PATCH 37/55] #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 62be66205cdbb91e43b3438eae396b660dff199c Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 31 Aug 2023 10:57:45 +0100 Subject: [PATCH 38/55] Fix unit tests --- .../_primaite/_simulator/_network/_hardware/test_nic.py | 2 +- tests/unit_tests/_primaite/_simulator/test_core.py | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) 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..c417b5b9 100644 --- a/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py +++ b/tests/unit_tests/_primaite/_simulator/_network/_hardware/test_nic.py @@ -48,7 +48,7 @@ def test_nic_deserialize(): nic_json = nic.model_dump_json() deserialized_nic = NIC.model_validate_json(nic_json) - assert nic == deserialized_nic + assert nic_json == deserialized_nic.model_dump_json() def test_nic_ip_address_as_gateway_fails(): diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py index 0d227633..bbb1298f 100644 --- a/tests/unit_tests/_primaite/_simulator/test_core.py +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -43,4 +43,5 @@ class TestIsolatedSimComponent: comp = TestComponent(name="computer", size=(5, 10)) dump = comp.model_dump_json() - assert comp == TestComponent.model_validate_json(dump) + reconstructed = TestComponent.model_validate_json(dump) + assert comp == reconstructed.model_dump_json() From e73d7f49d68e5e6b0e481db098d1e0aa49c044fd Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 31 Aug 2023 11:03:38 +0100 Subject: [PATCH 39/55] #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 7759c178bbe68c63430181c637a96a06c3c61d48 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 31 Aug 2023 11:20:16 +0100 Subject: [PATCH 40/55] Add logging and service restarting --- .../simulator/network/hardware/base.py | 2 ++ .../simulator/system/services/service.py | 24 ++++++++++++------- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/primaite/simulator/network/hardware/base.py b/src/primaite/simulator/network/hardware/base.py index e3e38f86..2bdb4b55 100644 --- a/src/primaite/simulator/network/hardware/base.py +++ b/src/primaite/simulator/network/hardware/base.py @@ -1007,6 +1007,7 @@ class Node(SimComponent): self.services[service.uuid] = service service.parent = self service.install() # Perform any additional setup, such as creating files for this service on the node. + self.sys_log.info(f"Installed service {service.name}") _LOGGER.info(f"Added service {service.uuid} to node {self.uuid}") def uninstall_service(self, service: Service) -> None: @@ -1021,6 +1022,7 @@ class Node(SimComponent): service.uninstall() # Perform additional teardown, such as removing files or restarting the machine. self.services.pop(service.uuid) service.parent = None + self.sys_log.info(f"Uninstalled service {service.name}") _LOGGER.info(f"Removed service {service.uuid} from node {self.uuid}") def __contains__(self, item: Any) -> bool: diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 7e67d05f..6932ce4c 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,6 +1,6 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict +from typing import Any, Dict, Optional from primaite.simulator.core import Action, ActionManager from primaite.simulator.system.software import IOSoftware @@ -32,6 +32,10 @@ class Service(IOSoftware): operating_state: ServiceOperatingState "The current operating state of the Service." + restart_duration: int = 5 + "How many timesteps does it take to restart this service." + _restart_countdown: Optional[int] = None + "If currently restarting, how many timesteps remain until the restart is finished." def _init_action_manager(self) -> ActionManager: am = super()._init_action_manager() @@ -97,41 +101,43 @@ class Service(IOSoftware): def stop(self) -> None: """Stop the service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: + self.parent.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED def start(self) -> None: """Start the service.""" if self.operating_state == ServiceOperatingState.STOPPED: + self.parent.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING def pause(self) -> None: """Pause the service.""" if self.operating_state == ServiceOperatingState.RUNNING: + self.parent.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED def resume(self) -> None: """Resume paused service.""" if self.operating_state == ServiceOperatingState.PAUSED: + self.parent.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING def restart(self) -> None: """Restart running service.""" if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: + self.parent.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING - self.restart_countdown = 5 # TODO: implement restart duration + self.restart_countdown = self.restarting_duration # TODO: implement restart duration def disable(self) -> None: """Disable the service.""" - if self.operating_state in [ - ServiceOperatingState.RUNNING, - ServiceOperatingState.STOPPED, - ServiceOperatingState.PAUSED, - ]: - self.operating_state = ServiceOperatingState.DISABLED + self.parent.sys_log.info(f"Disabling Application {self.name}") + self.operating_state = ServiceOperatingState.DISABLED def enable(self) -> None: """Enable the disabled service.""" if self.operating_state == ServiceOperatingState.DISABLED: + self.parent.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED def apply_timestep(self, timestep: int) -> None: @@ -146,6 +152,6 @@ class Service(IOSoftware): """ super().apply_timestep(timestep) if self.operating_state == ServiceOperatingState.RESTARTING: - self.restart_countdown -= 1 if self.restart_countdown <= 0: self.operating_state = ServiceOperatingState.RUNNING + self.restart_countdown -= 1 From f60f775f03a9a4ef44d4c3e491d0bf5e50a7111d Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 31 Aug 2023 11:27:52 +0100 Subject: [PATCH 41/55] Improve logging --- src/primaite/simulator/system/services/service.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 6932ce4c..756f723d 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -2,9 +2,12 @@ from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional +from primaite import getLogger from primaite.simulator.core import Action, ActionManager from primaite.simulator.system.software import IOSoftware +_LOGGER = getLogger(__name__) + class ServiceOperatingState(Enum): """Enumeration of Service Operating States.""" @@ -95,35 +98,37 @@ class Service(IOSoftware): """ pass - # TODO: validate this state transition model. - # Possibly state transition could be defined more succinctly than a separate function with lots of if statements. - def stop(self) -> None: """Stop the service.""" + _LOGGER.debug(f"Stopping service {self.name}") if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.parent.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED def start(self) -> None: """Start the service.""" + _LOGGER.debug(f"Starting service {self.name}") if self.operating_state == ServiceOperatingState.STOPPED: self.parent.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING def pause(self) -> None: """Pause the service.""" + _LOGGER.debug(f"Pausing service {self.name}") if self.operating_state == ServiceOperatingState.RUNNING: self.parent.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED def resume(self) -> None: """Resume paused service.""" + _LOGGER.debug(f"Resuming service {self.name}") if self.operating_state == ServiceOperatingState.PAUSED: self.parent.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING def restart(self) -> None: """Restart running service.""" + _LOGGER.debug(f"Restarting service {self.name}") if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.parent.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING @@ -131,11 +136,13 @@ class Service(IOSoftware): def disable(self) -> None: """Disable the service.""" + _LOGGER.debug(f"Disabling service {self.name}") self.parent.sys_log.info(f"Disabling Application {self.name}") self.operating_state = ServiceOperatingState.DISABLED def enable(self) -> None: """Enable the disabled service.""" + _LOGGER.debug(f"Enabling service {self.name}") if self.operating_state == ServiceOperatingState.DISABLED: self.parent.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED @@ -153,5 +160,6 @@ class Service(IOSoftware): super().apply_timestep(timestep) if self.operating_state == ServiceOperatingState.RESTARTING: if self.restart_countdown <= 0: + _LOGGER.debug(f"Restarting finished for service {self.name}") self.operating_state = ServiceOperatingState.RUNNING self.restart_countdown -= 1 From bd5aacaf0c33ee56650cf6e11e60f0d7b9d9641e Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 31 Aug 2023 11:32:11 +0100 Subject: [PATCH 42/55] Remove todo comments --- src/primaite/simulator/system/services/database.py | 11 +++++++++-- src/primaite/simulator/system/services/service.py | 2 +- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/primaite/simulator/system/services/database.py b/src/primaite/simulator/system/services/database.py index 554455b8..23b856f7 100644 --- a/src/primaite/simulator/system/services/database.py +++ b/src/primaite/simulator/system/services/database.py @@ -6,10 +6,17 @@ from primaite.simulator.system.services.service import Service class DatabaseService(Service): - """TODO.""" + """Service loosely modelled on Microsoft SQL Server.""" def describe_state(self) -> Dict: - """TODO.""" + """ + 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 super().describe_state() def uninstall(self) -> None: diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 756f723d..f9cc784d 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -132,7 +132,7 @@ class Service(IOSoftware): if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: self.parent.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING - self.restart_countdown = self.restarting_duration # TODO: implement restart duration + self.restart_countdown = self.restarting_duration def disable(self) -> None: """Disable the service.""" From 89ad22acebbd39cc6bf7a30808b104f46bd1152b Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Thu, 31 Aug 2023 13:35:56 +0100 Subject: [PATCH 43/55] #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 61fa83a00d81e8967f13966bab99e898b93552f3 Mon Sep 17 00:00:00 2001 From: Marek Wolan Date: Thu, 31 Aug 2023 14:55:14 +0100 Subject: [PATCH 44/55] Fix failing test --- tests/unit_tests/_primaite/_simulator/test_core.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/unit_tests/_primaite/_simulator/test_core.py b/tests/unit_tests/_primaite/_simulator/test_core.py index bbb1298f..069e6ea2 100644 --- a/tests/unit_tests/_primaite/_simulator/test_core.py +++ b/tests/unit_tests/_primaite/_simulator/test_core.py @@ -44,4 +44,4 @@ class TestIsolatedSimComponent: comp = TestComponent(name="computer", size=(5, 10)) dump = comp.model_dump_json() reconstructed = TestComponent.model_validate_json(dump) - assert comp == reconstructed.model_dump_json() + assert dump == reconstructed.model_dump_json() From 5111affeebbc8a75becbfa2c31b34eed4a8a9ebc Mon Sep 17 00:00:00 2001 From: Chris McCarthy Date: Fri, 1 Sep 2023 16:58:21 +0100 Subject: [PATCH 45/55] #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 46/55] #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 47/55] 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 48/55] #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 49/55] #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 50/55] #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 51/55] 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 52/55] 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 From c349bb4484dfa39a434e9bb076528b516b6a96d9 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Tue, 5 Sep 2023 17:14:47 +0100 Subject: [PATCH 53/55] #1814: initial implementation of data manipulator service --- .../network/transmission/transport_layer.py | 2 + .../simulator/system/core/session_manager.py | 95 +++++++++++++++---- .../simulator/system/core/software_manager.py | 51 ++++++++-- .../red_services/data_manipulator_service.py | 28 ++++++ .../simulator/system/services/service.py | 4 +- src/primaite/simulator/system/software.py | 18 ++-- .../system/test_database_on_node.py | 8 +- .../_system/_services/test_database.py | 4 +- 8 files changed, 163 insertions(+), 47 deletions(-) create mode 100644 src/primaite/simulator/system/services/red_services/data_manipulator_service.py diff --git a/src/primaite/simulator/network/transmission/transport_layer.py b/src/primaite/simulator/network/transmission/transport_layer.py index b95b4a74..d4318baf 100644 --- a/src/primaite/simulator/network/transmission/transport_layer.py +++ b/src/primaite/simulator/network/transmission/transport_layer.py @@ -59,6 +59,8 @@ class Port(Enum): "Alternative port for HTTP (HTTP_ALT) - Often used as an alternative HTTP port for web applications." HTTPS_ALT = 8443 "Alternative port for HTTPS (HTTPS_ALT) - Used in some configurations for secure web traffic." + POSTGRES_SERVER = 5432 + "Postgres SQL Server." class UDPHeader(BaseModel): diff --git a/src/primaite/simulator/system/core/session_manager.py b/src/primaite/simulator/system/core/session_manager.py index 7f3d22c5..be20a28d 100644 --- a/src/primaite/simulator/system/core/session_manager.py +++ b/src/primaite/simulator/system/core/session_manager.py @@ -1,12 +1,14 @@ from __future__ import annotations from ipaddress import IPv4Address -from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING +from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING, Union + +from prettytable import MARKDOWN, PrettyTable from primaite.simulator.core import SimComponent -from primaite.simulator.network.transmission.data_link_layer import Frame -from primaite.simulator.network.transmission.network_layer import IPProtocol -from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.network.transmission.data_link_layer import EthernetHeader, Frame +from primaite.simulator.network.transmission.network_layer import IPPacket, IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port, TCPHeader if TYPE_CHECKING: from primaite.simulator.network.hardware.base import ARPCache @@ -135,7 +137,14 @@ class SessionManager: dst_port = None 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): + def receive_payload_from_software_manager( + self, + payload: Any, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + session_id: Optional[str] = None, + is_reattempt: bool = False, + ) -> Union[Any, None]: """ Receive a payload from the SoftwareManager. @@ -144,9 +153,50 @@ class SessionManager: :param payload: The payload to be sent. :param session_id: The Session ID the payload is to originate from. Optional. If None, one will be created. """ - # TODO: Implement session creation and + if session_id: + dest_ip_address = self.sessions_by_uuid[session_id].dst_ip_address + dest_port = self.sessions_by_uuid[session_id].dst_port - self.send_payload_to_nic(payload, session_id) + dst_mac_address = self.arp_cache.get_arp_cache_mac_address(dest_ip_address) + + if dst_mac_address: + outbound_nic = self.arp_cache.get_arp_cache_nic(dest_ip_address) + else: + if not is_reattempt: + self.arp_cache.send_arp_request(dest_ip_address) + return self.receive_payload_from_software_manager( + payload=payload, + dest_ip_address=dest_ip_address, + dest_port=dest_port, + session_id=session_id, + is_reattempt=True, + ) + else: + return + + frame = Frame( + ethernet=EthernetHeader(src_mac_addr=outbound_nic.mac_address, dst_mac_addr=dst_mac_address), + ip=IPPacket( + src_ip_address=outbound_nic.ip_address, + dst_ip_address=dest_ip_address, + ), + tcp=TCPHeader( + src_port=dest_port, + dst_port=dest_port, + ), + payload=payload, + ) + + if not session_id: + session_key = self._get_session_key(frame, from_source=True) + session = self.sessions_by_key.get(session_key) + if not session: + # Create new session + session = Session.from_session_key(session_key) + self.sessions_by_key[session_key] = session + self.sessions_by_uuid[session.uuid] = session + + outbound_nic.send_frame(frame) def send_payload_to_software_manager(self, payload: Any, session_id: int): """ @@ -157,18 +207,6 @@ class SessionManager: """ self.software_manager.receive_payload_from_session_manger() - def send_payload_to_nic(self, payload: Any, session_id: int): - """ - Send a payload across the Network. - - Takes a payload and a session_id. Builds a Frame and sends it across the network via a NIC. - - :param payload: The payload to be sent. - :param session_id: The Session ID the payload originates from - """ - # TODO: Implement frame construction and sent to NIC. - pass - def receive_payload_from_nic(self, frame: Frame): """ Receive a Frame from the NIC. @@ -187,3 +225,22 @@ class SessionManager: self.sessions_by_uuid[session.uuid] = session self.software_manager.receive_payload_from_session_manger(payload=frame, session=session) # TODO: Implement the frame deconstruction and send to SoftwareManager. + + def show(self, markdown: bool = False): + """ + Print tables describing the SessionManager. + + Generate and print PrettyTable instances that show details about + session's destination IP Address, destination Ports and the protocol to use. + Output can be in Markdown format. + + :param markdown: Use Markdown style in table output. Defaults to False. + """ + table = PrettyTable(["Destination IP", "Port", "Protocol"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} Session Manager" + for session in self.sessions_by_key.values(): + table.add_row([session.dst_ip_address, session.dst_port.value, session.protocol.name]) + print(table) diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index 411fb6e9..312f6d84 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -1,5 +1,8 @@ +from ipaddress import IPv4Address from typing import Any, Dict, Optional, Tuple, TYPE_CHECKING, Union +from prettytable import MARKDOWN, PrettyTable + from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port from primaite.simulator.system.applications.application import Application @@ -12,6 +15,10 @@ if TYPE_CHECKING: from primaite.simulator.system.core.session_manager import SessionManager from primaite.simulator.system.core.sys_log import SysLog +from typing import Type, TypeVar + +ServiceClass = TypeVar("ServiceClass", bound=Service) + class SoftwareManager: """A class that manages all running Services and Applications on a Node and facilitates their communication.""" @@ -28,18 +35,17 @@ class SoftwareManager: self.port_protocol_mapping: Dict[Tuple[Port, IPProtocol], Union[Service, Application]] = {} self.sys_log: SysLog = sys_log - def add_service(self, name: str, service: Service, port: Port, protocol: IPProtocol): + def add_service(self, service_class: Type[ServiceClass]): """ Add a Service to the manager. - :param name: The name of the service. - :param service: The service instance. - :param port: The port used by the service. - :param protocol: The network protocol used by the service. + :param service_class: The class of the service to add """ + service = service_class(software_manager=self, sys_log=self.sys_log) + service.software_manager = self - self.services[name] = service - self.port_protocol_mapping[(port, protocol)] = service + self.services[service.name] = service + self.port_protocol_mapping[(service.port, service.protocol)] = service def add_application(self, name: str, application: Application, port: Port, protocol: IPProtocol): """ @@ -75,14 +81,24 @@ class SoftwareManager: else: raise ValueError(f"No {target_software_type.name.lower()} found with the name {target_software}") - def send_payload_to_session_manger(self, payload: Any, session_id: Optional[int] = None): + def send_payload_to_session_manager( + self, + payload: Any, + dest_ip_address: Optional[IPv4Address] = None, + dest_port: Optional[Port] = None, + session_id: Optional[int] = None, + ): """ Send a payload to the SessionManager. :param payload: The payload to be sent. + :param dest_ip_address: The ip address of the payload destination. + :param dest_port: The port of the payload destination. :param session_id: The Session ID the payload is to originate from. Optional. """ - self.session_manager.receive_payload_from_software_manager(payload, session_id) + self.session_manager.receive_payload_from_software_manager( + payload=payload, dest_ip_address=dest_ip_address, dest_port=dest_port, session_id=session_id + ) def receive_payload_from_session_manger(self, payload: Any, session: Session): """ @@ -97,3 +113,20 @@ class SoftwareManager: # else: # raise ValueError(f"No service or application found for port {port} and protocol {protocol}") pass + + 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(["Name", "Operating State", "Health State", "Port"]) + if markdown: + table.set_style(MARKDOWN) + table.align = "l" + table.title = f"{self.sys_log.hostname} Software Manager" + for service in self.services.values(): + table.add_row( + [service.name, service.operating_state.name, service.health_state_actual.name, service.port.value] + ) + print(table) diff --git a/src/primaite/simulator/system/services/red_services/data_manipulator_service.py b/src/primaite/simulator/system/services/red_services/data_manipulator_service.py new file mode 100644 index 00000000..29f0d3f8 --- /dev/null +++ b/src/primaite/simulator/system/services/red_services/data_manipulator_service.py @@ -0,0 +1,28 @@ +from ipaddress import IPv4Address + +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.software_manager import SoftwareManager +from primaite.simulator.system.services.service import Service + + +class DataManipulatorService(Service): + """ + Red Agent Data Integration Service. + + The Service represents a bot that causes files/folders in the File System to + become corrupted. + """ + + def __init__(self, **kwargs): + kwargs["name"] = "DataManipulatorBot" + kwargs["port"] = Port.POSTGRES_SERVER + kwargs["protocol"] = IPProtocol.TCP + super().__init__(**kwargs) + + def run(self): + """Run the DataManipulatorService actions.""" + software_manager: SoftwareManager = self.software_manager + software_manager.send_payload_to_session_manager( + payload="SELECT * FROM users", dest_ip_address=IPv4Address("192.168.1.14"), dest_port=self.port + ) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index f9cc784d..6a8c9abf 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -1,4 +1,3 @@ -from abc import abstractmethod from enum import Enum from typing import Any, Dict, Optional @@ -33,7 +32,7 @@ class Service(IOSoftware): Services are programs that run in the background and may perform input/output operations. """ - operating_state: ServiceOperatingState + operating_state: ServiceOperatingState = ServiceOperatingState.STOPPED "The current operating state of the Service." restart_duration: int = 5 "How many timesteps does it take to restart this service." @@ -51,7 +50,6 @@ class Service(IOSoftware): am.add_action("enable", Action(func=lambda request, context: self.enable())) return am - @abstractmethod def describe_state(self) -> Dict: """ Produce a dictionary describing the current state of this object. diff --git a/src/primaite/simulator/system/software.py b/src/primaite/simulator/system/software.py index 605a062b..7f206311 100644 --- a/src/primaite/simulator/system/software.py +++ b/src/primaite/simulator/system/software.py @@ -1,9 +1,10 @@ from abc import abstractmethod from enum import Enum -from typing import Any, Dict, Set +from typing import Any, Dict from primaite.simulator.core import Action, ActionManager, SimComponent from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.core.sys_log import SysLog class SoftwareType(Enum): @@ -62,11 +63,11 @@ class Software(SimComponent): name: str "The name of the software." - health_state_actual: SoftwareHealthState + health_state_actual: SoftwareHealthState = SoftwareHealthState.GOOD "The actual health state of the software." - health_state_visible: SoftwareHealthState + health_state_visible: SoftwareHealthState = SoftwareHealthState.GOOD "The health state of the software visible to the red agent." - criticality: SoftwareCriticality + criticality: SoftwareCriticality = SoftwareCriticality.LOWEST "The criticality level of the software." patching_count: int = 0 "The count of patches applied to the software, defaults to 0." @@ -74,6 +75,10 @@ class Software(SimComponent): "The count of times the software has been scanned, defaults to 0." revealed_to_red: bool = False "Indicates if the software has been revealed to red agent, defaults is False." + software_manager: Any = None + "An instance of Software Manager that is used by the parent node." + sys_log: SysLog = None + "An instance of SysLog that is used by the parent node." def _init_action_manager(self) -> ActionManager: am = super()._init_action_manager() @@ -132,7 +137,6 @@ class Software(SimComponent): """ self.health_state_actual = health_state - @abstractmethod def install(self) -> None: """ Perform first-time setup of this service on a node. @@ -175,8 +179,8 @@ class IOSoftware(Software): "Indicates if the software uses TCP protocol for communication. Default is True." udp: bool = True "Indicates if the software uses UDP protocol for communication. Default is True." - ports: Set[Port] - "The set of ports to which the software is connected." + port: Port + "The port to which the software is connected." @abstractmethod def describe_state(self) -> Dict: diff --git a/tests/integration_tests/system/test_database_on_node.py b/tests/integration_tests/system/test_database_on_node.py index 73d19339..058bb590 100644 --- a/tests/integration_tests/system/test_database_on_node.py +++ b/tests/integration_tests/system/test_database_on_node.py @@ -11,9 +11,7 @@ def test_installing_database(): health_state_actual=SoftwareHealthState.GOOD, health_state_visible=SoftwareHealthState.GOOD, criticality=SoftwareCriticality.MEDIUM, - ports=[ - Port.SQL_SERVER, - ], + port=Port.SQL_SERVER, operating_state=ServiceOperatingState.RUNNING, ) @@ -40,9 +38,7 @@ def test_uninstalling_database(): health_state_actual=SoftwareHealthState.GOOD, health_state_visible=SoftwareHealthState.GOOD, criticality=SoftwareCriticality.MEDIUM, - ports=[ - Port.SQL_SERVER, - ], + port=Port.SQL_SERVER, operating_state=ServiceOperatingState.RUNNING, ) diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py b/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py index ea5c1b83..ebc5536f 100644 --- a/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/test_database.py @@ -10,8 +10,6 @@ def test_creation(): health_state_actual=SoftwareHealthState.GOOD, health_state_visible=SoftwareHealthState.GOOD, criticality=SoftwareCriticality.MEDIUM, - ports=[ - Port.SQL_SERVER, - ], + port=Port.SQL_SERVER, operating_state=ServiceOperatingState.RUNNING, ) From d503e51c2ddc9945d20de5d487552b21719c8797 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 6 Sep 2023 11:12:03 +0100 Subject: [PATCH 54/55] #1814: Remove hardcoded values + added test + remove unnecessary private parent attribute --- src/primaite/simulator/core.py | 25 ++------------- .../simulator/system/core/software_manager.py | 2 +- .../red_services/data_manipulator_service.py | 18 +++++++---- .../simulator/system/services/service.py | 16 +++++----- .../_services/_red_services/__init__.py | 0 .../test_data_manipulator_service.py | 32 +++++++++++++++++++ 6 files changed, 55 insertions(+), 38 deletions(-) create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/__init__.py create mode 100644 tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulator_service.py diff --git a/src/primaite/simulator/core.py b/src/primaite/simulator/core.py index 5ae7b492..32db95c6 100644 --- a/src/primaite/simulator/core.py +++ b/src/primaite/simulator/core.py @@ -1,6 +1,6 @@ """Core of the PrimAITE Simulator.""" from abc import ABC, abstractmethod -from typing import Callable, Dict, List, Optional, Union +from typing import Callable, Dict, List, Optional from uuid import uuid4 from pydantic import BaseModel, ConfigDict @@ -140,7 +140,7 @@ class SimComponent(BaseModel): kwargs["uuid"] = str(uuid4()) super().__init__(**kwargs) self._action_manager: ActionManager = self._init_action_manager() - self._parent: Optional["SimComponent"] = None + self.parent: Optional["SimComponent"] = None def _init_action_manager(self) -> ActionManager: """ @@ -213,24 +213,3 @@ class SimComponent(BaseModel): Override this method with anything that needs to happen within the component for it to be reset. """ pass - - @property - def parent(self) -> "SimComponent": - """Reference to the parent object which manages this object. - - :return: Parent object. - :rtype: SimComponent - """ - return self._parent - - @parent.setter - def parent(self, new_parent: Union["SimComponent", None]) -> None: - if self._parent and new_parent: - msg = f"Overwriting parent of {self.uuid}. Old parent: {self._parent.uuid}, New parent: {new_parent.uuid}" - _LOGGER.warn(msg) - raise RuntimeWarning(msg) - self._parent = new_parent - - @parent.deleter - def parent(self) -> None: - self._parent = None diff --git a/src/primaite/simulator/system/core/software_manager.py b/src/primaite/simulator/system/core/software_manager.py index 312f6d84..28e37963 100644 --- a/src/primaite/simulator/system/core/software_manager.py +++ b/src/primaite/simulator/system/core/software_manager.py @@ -39,7 +39,7 @@ class SoftwareManager: """ Add a Service to the manager. - :param service_class: The class of the service to add + :param: service_class: The class of the service to add """ service = service_class(software_manager=self, sys_log=self.sys_log) diff --git a/src/primaite/simulator/system/services/red_services/data_manipulator_service.py b/src/primaite/simulator/system/services/red_services/data_manipulator_service.py index 29f0d3f8..82b9aa1c 100644 --- a/src/primaite/simulator/system/services/red_services/data_manipulator_service.py +++ b/src/primaite/simulator/system/services/red_services/data_manipulator_service.py @@ -1,8 +1,8 @@ from ipaddress import IPv4Address +from typing import Any, Optional from primaite.simulator.network.transmission.network_layer import IPProtocol from primaite.simulator.network.transmission.transport_layer import Port -from primaite.simulator.system.core.software_manager import SoftwareManager from primaite.simulator.system.services.service import Service @@ -20,9 +20,15 @@ class DataManipulatorService(Service): kwargs["protocol"] = IPProtocol.TCP super().__init__(**kwargs) - def run(self): - """Run the DataManipulatorService actions.""" - software_manager: SoftwareManager = self.software_manager - software_manager.send_payload_to_session_manager( - payload="SELECT * FROM users", dest_ip_address=IPv4Address("192.168.1.14"), dest_port=self.port + def start(self, target_ip_address: IPv4Address, payload: Optional[Any] = "DELETE TABLE users", **kwargs): + """ + Run the DataManipulatorService actions. + + :param: target_ip_address: The IP address of the target machine to attack + :param: payload: The payload to send to the target machine + """ + super().start() + + self.software_manager.send_payload_to_session_manager( + payload=payload, dest_ip_address=target_ip_address, dest_port=self.port ) diff --git a/src/primaite/simulator/system/services/service.py b/src/primaite/simulator/system/services/service.py index 6a8c9abf..b9340103 100644 --- a/src/primaite/simulator/system/services/service.py +++ b/src/primaite/simulator/system/services/service.py @@ -100,49 +100,49 @@ class Service(IOSoftware): """Stop the service.""" _LOGGER.debug(f"Stopping service {self.name}") if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: - self.parent.sys_log.info(f"Stopping service {self.name}") + self.sys_log.info(f"Stopping service {self.name}") self.operating_state = ServiceOperatingState.STOPPED - def start(self) -> None: + def start(self, **kwargs) -> None: """Start the service.""" _LOGGER.debug(f"Starting service {self.name}") if self.operating_state == ServiceOperatingState.STOPPED: - self.parent.sys_log.info(f"Starting service {self.name}") + self.sys_log.info(f"Starting service {self.name}") self.operating_state = ServiceOperatingState.RUNNING def pause(self) -> None: """Pause the service.""" _LOGGER.debug(f"Pausing service {self.name}") if self.operating_state == ServiceOperatingState.RUNNING: - self.parent.sys_log.info(f"Pausing service {self.name}") + self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.PAUSED def resume(self) -> None: """Resume paused service.""" _LOGGER.debug(f"Resuming service {self.name}") if self.operating_state == ServiceOperatingState.PAUSED: - self.parent.sys_log.info(f"Resuming service {self.name}") + self.sys_log.info(f"Resuming service {self.name}") self.operating_state = ServiceOperatingState.RUNNING def restart(self) -> None: """Restart running service.""" _LOGGER.debug(f"Restarting service {self.name}") if self.operating_state in [ServiceOperatingState.RUNNING, ServiceOperatingState.PAUSED]: - self.parent.sys_log.info(f"Pausing service {self.name}") + self.sys_log.info(f"Pausing service {self.name}") self.operating_state = ServiceOperatingState.RESTARTING self.restart_countdown = self.restarting_duration def disable(self) -> None: """Disable the service.""" _LOGGER.debug(f"Disabling service {self.name}") - self.parent.sys_log.info(f"Disabling Application {self.name}") + self.sys_log.info(f"Disabling Application {self.name}") self.operating_state = ServiceOperatingState.DISABLED def enable(self) -> None: """Enable the disabled service.""" _LOGGER.debug(f"Enabling service {self.name}") if self.operating_state == ServiceOperatingState.DISABLED: - self.parent.sys_log.info(f"Enabling Application {self.name}") + self.sys_log.info(f"Enabling Application {self.name}") self.operating_state = ServiceOperatingState.STOPPED def apply_timestep(self, timestep: int) -> None: diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/__init__.py b/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulator_service.py b/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulator_service.py new file mode 100644 index 00000000..f5b37175 --- /dev/null +++ b/tests/unit_tests/_primaite/_simulator/_system/_services/_red_services/test_data_manipulator_service.py @@ -0,0 +1,32 @@ +from ipaddress import IPv4Address + +from primaite.simulator.network.hardware.base import Node +from primaite.simulator.network.networks import arcd_uc2_network +from primaite.simulator.network.transmission.network_layer import IPProtocol +from primaite.simulator.network.transmission.transport_layer import Port +from primaite.simulator.system.services.red_services.data_manipulator_service import DataManipulatorService + + +def test_creation(): + network = arcd_uc2_network() + + client_1: Node = network.get_node_by_hostname("client_1") + + client_1.software_manager.add_service(service_class=DataManipulatorService) + + data_manipulator_service: DataManipulatorService = client_1.software_manager.services["DataManipulatorBot"] + + assert data_manipulator_service.name == "DataManipulatorBot" + assert data_manipulator_service.port == Port.POSTGRES_SERVER + assert data_manipulator_service.protocol == IPProtocol.TCP + + # should have no session yet + assert len(client_1.session_manager.sessions_by_uuid) == 0 + + try: + data_manipulator_service.start(target_ip_address=IPv4Address("192.168.1.14")) + except Exception as e: + assert False, f"Test was not supposed to throw exception: {e}" + + # there should be a session after the service is started + assert len(client_1.session_manager.sessions_by_uuid) == 1 From 597c7664bc7027566a1a8d0339c536de72a223e4 Mon Sep 17 00:00:00 2001 From: Czar Echavez Date: Wed, 6 Sep 2023 11:19:30 +0100 Subject: [PATCH 55/55] #1814: update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f2918aa..14a53d73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,6 +27,8 @@ SessionManager. - File System - ability to emulate a node's file system during a simulation - Example notebooks - There is currently 1 jupyter notebook which walks through using PrimAITE 1. Creating a simulation - this notebook explains how to build up a simulation using the Python package. (WIP) +- Red Agent Services: + - Data Manipulator Bot - A red agent service which sends a payload to a target machine. (By default this payload is a SQL query that breaks a database) ## [2.0.0] - 2023-07-26