diff --git a/labs/ICS-Simulation/.gitignore b/labs/ICS-Simulation/.gitignore
new file mode 100644
index 0000000..97b6d25
--- /dev/null
+++ b/labs/ICS-Simulation/.gitignore
@@ -0,0 +1,5 @@
+.terraform/
+.terraform.lock.hcl
+infra_outputs.json
+*.retry
+ansible/inventory/inventory.yml
diff --git a/labs/ICS-Simulation/README.md b/labs/ICS-Simulation/README.md
new file mode 100644
index 0000000..7c1eb91
--- /dev/null
+++ b/labs/ICS-Simulation/README.md
@@ -0,0 +1,678 @@
+# ICS Simulation Lab
+
+This repository builds a small, segmented industrial control system lab on top of Incus. It is designed for local research, demonstrations, automation testing, packet capture exercises, and security validation. It is intentionally simple and portable. It is not a production ICS reference architecture.
+
+The stack has three layers:
+
+- OpenTofu creates the Incus project, bridges, base profile, and system containers.
+- Ansible configures the guests and installs the simulated ICS services.
+- Helper scripts generate inventory, validate the deployment, and optionally launch OCI application containers.
+
+## Topology Schema
+
+```text
+ +----------------------+
+ | wan-test01 |
+ | 198.18.110.10 |
+ | WAN validation host |
+ +----------+-----------+
+ |
+ ics-wan |
+ 198.18.110.0/24 |
+ |
+ +----------+-----------+
+ | fw01 |
+ | Router / Firewall |
+ | DNS / NAT / Gateway |
+ | .110.254 .10.254 |
+ | .20.254 .30.254 |
+ | .40.254 |
+ +---+---------+--------+
+ | |
+ +-----------------+ +--------------------+
+ | ics-ot-dmz
+ | ics-it 198.18.20.0/24
+ | 198.18.10.0/24 |
+ | |
+ +---------------+---------------+ +--------------+--------------+
+ | | | | |
++----+-----+ +-----+-----+ +-----+------+ +-----+------+ +------+------+
+| it-file01| | it-ws01 | |it-activity01| |otdmz-jump01| | mqtt01* |
+| SMB file | | test host | | sample user | | SSH pivot | | optional |
+| server | | / tooling | | activity | | host | | MQTT broker |
++----------+ +-----------+ +-------------+ +------------+ +-------------+
+
+ DMZ -> OPS: tcp/22,443
+ OPS -> DMZ: tcp/1883
+
+ ics-ot-ops 198.18.30.0/24
+ |
+ +--------------------------+--------------------------+
+ | |
+ +-----+------+ +------+------+
+ |otops-scada01| | otops-hmi01 |
+ | SCADA poller| | HMI web UI |
+ | API :8081 | | Web :8080 |
+ +-----+------+ +------+------+
+ |
+ | OPS -> CELL: tcp/502
+ |
+ ics-ot-cell 198.18.40.0/24
+ |
+ +---------------+----------------+
+ | |
+ +-----+------+ +-----+------+
+ |otcell-plc01| |otcell- |
+ | PLC sim |<--- HTTP :8080 --|process01 |
+ | Modbus :502 | | tank model |
+ +------------+ +------------+
+```
+
+`*` Optional OCI containers are not part of the default base deployment.
+
+## What The Lab Simulates
+
+At runtime, the lab models a small OT stack with a simple process loop:
+
+1. `otcell-process01` simulates a tank process and exposes its state over HTTP.
+2. `otcell-plc01` polls that process over HTTP, converts the values into registers, and serves them over Modbus/TCP on port `502`.
+3. `otops-scada01` polls the PLC over Modbus/TCP, scales the register values into tags, and exposes them through a small HTTP API.
+4. `otops-hmi01` renders a browser-based dashboard that fetches tag data from the SCADA API every two seconds.
+
+The core data path is:
+
+```text
+Process HTTP -> PLC Modbus/TCP -> SCADA API -> HMI
+```
+
+The network policy intentionally prevents most direct shortcuts between zones, so the interesting traffic follows controlled north-south paths through `fw01`.
+
+## Segmentation Model
+
+The default deployment creates five routed segments:
+
+- `ics-wan`: `198.18.110.0/24`
+- `ics-it`: `198.18.10.0/24`
+- `ics-ot-dmz`: `198.18.20.0/24`
+- `ics-ot-ops`: `198.18.30.0/24`
+- `ics-ot-cell`: `198.18.40.0/24`
+
+`fw01` is the only node attached to every segment. It provides:
+
+- inter-zone routing
+- default gateway service for internal segments
+- outbound source NAT toward the WAN bridge
+- DNS forwarding through `dnsmasq`
+- inter-zone filtering through `nftables`
+
+The firewall policy is deny-by-default between zones, with these explicit allows:
+
+- IT -> DMZ: `tcp/22`
+- DMZ -> OPS: `tcp/22`, `tcp/443`
+- OPS -> CELL: `tcp/502`
+- OPS -> DMZ: `tcp/1883`
+- Internal zones -> WAN: allowed and source-NATed on `fw01`
+- WAN -> internal zones: dropped
+
+Important detail:
+
+- `DMZ -> OPS tcp/443` is allowed at the firewall even though the base lab does not expose a service there by default.
+- `OPS -> DMZ tcp/1883` is allowed so an optional MQTT broker such as `mqtt01` can be added later.
+
+The current live validation should look like this:
+
+- IT can reach the jump host over SSH.
+- IT cannot directly reach OPS or CELL services.
+- SCADA in OPS can reach the PLC in CELL on `502`.
+- WAN cannot directly initiate connections into IT, DMZ, OPS, or CELL.
+
+## Default Containers And Their Purpose
+
+The base lab deploys one Incus project named `ICS-simulation`, five bridges, and ten system containers.
+
+### `fw01`
+
+Purpose:
+
+- router
+- firewall
+- DNS forwarder
+- outbound NAT gateway
+
+What runs there:
+
+- `nftables`
+- `dnsmasq`
+- SSH
+
+Why it matters:
+
+- It is the main packet capture point for routed inter-zone traffic.
+- It enforces the lab segmentation policy.
+
+### `wan-test01`
+
+Purpose:
+
+- WAN-side validation host
+- used to prove that inbound traffic is blocked
+
+What is installed there:
+
+- troubleshooting tools from Ansible such as `ping`, `traceroute`, and `tcpdump`
+
+Why it matters:
+
+- It gives you an external-looking vantage point for testing exposure.
+
+### `it-file01`
+
+Purpose:
+
+- SMB file server for the IT zone
+
+What runs there:
+
+- Samba
+
+What it exposes:
+
+- `Engineering`
+- `Operations`
+- `Backups`
+
+Why it matters:
+
+- It gives the lab a simple business-side service and a place for file-oriented exercises.
+
+### `it-ws01`
+
+Purpose:
+
+- IT workstation and generic test client
+
+What is installed there:
+
+- troubleshooting tools
+- `smbclient`
+- `tcpdump`
+
+Why it matters:
+
+- It is the easiest place to exercise IT-to-DMZ and blocked IT-to-OT paths.
+
+### `it-activity01`
+
+Purpose:
+
+- sample activity runner host
+
+What is installed there:
+
+- `/opt/ics/activity/activity_runner.py`
+- `/opt/ics/activity/scenario.yaml`
+
+Why it matters:
+
+- It provides repeatable operator-style or validation-style actions without needing a browser.
+
+### `otdmz-jump01`
+
+Purpose:
+
+- SSH landing point in the OT DMZ
+
+What is installed there:
+
+- SSH client tooling
+
+Why it matters:
+
+- It is the intended first hop from IT into the OT side.
+
+### `otops-scada01`
+
+Purpose:
+
+- SCADA poller
+- tag normalizer
+- API server for downstream consumers
+
+What runs there:
+
+- `/opt/ics/scada/scada_api.py`
+- systemd unit `ics-scada.service`
+
+What it does:
+
+- polls the PLC on `198.18.40.10:502`
+- scales raw register values into tags
+- optionally checks whether an MQTT broker is reachable
+- serves tag state over HTTP on port `8081`
+
+### `otops-hmi01`
+
+Purpose:
+
+- web-based HMI
+
+What runs there:
+
+- `/opt/ics/hmi/hmi_server.py`
+- systemd unit `ics-hmi.service`
+
+What it does:
+
+- serves the HMI page on `198.18.30.20:8080`
+- proxies current SCADA tag data at `/api/tags`
+- refreshes the page data every two seconds
+
+### `otcell-plc01`
+
+Purpose:
+
+- PLC simulator
+
+What runs there:
+
+- `/opt/ics/plc/plc_sim.py`
+- systemd unit `ics-plc.service`
+
+What it does:
+
+- polls the process simulator over HTTP
+- stores register values in `/var/lib/ics-plc/state.json`
+- exposes Modbus/TCP on port `502`
+- accepts selected write operations that change process setpoint or pump state
+
+### `otcell-process01`
+
+Purpose:
+
+- process simulator
+
+What runs there:
+
+- `/opt/ics/process/tank_process.py`
+- systemd unit `ics-process.service`
+
+What it does:
+
+- simulates a tank level drifting around a setpoint
+- updates inlet flow, outlet flow, alarms, and pump state every second
+- serves the current process state over HTTP on port `8080`
+- accepts JSON commands on `/command`
+
+## Optional OCI Containers
+
+The helper script `scripts/oci_deploy.sh` can add OCI application containers:
+
+- `mqtt01` in `ics-ot-dmz`
+- `influxdb01` in `ics-ot-ops` when `DEPLOY_OBSERVABILITY=1`
+- `grafana01` in `ics-ot-ops` when `DEPLOY_OBSERVABILITY=1`
+
+These are optional extensions, not part of the default base lab. Some OCI images need static interface bootstrapping when attached to non-DHCP bridges. `scripts/oci-wrapper.sh` is included for that case.
+
+## How The Simulated Process Works
+
+The process model in `tank_process.py` is intentionally small and readable.
+
+- Initial state starts near a 52 percent tank level with a 55 percent setpoint.
+- Every second, the process calculates a new inlet flow and outlet flow.
+- If the level falls well below the setpoint, the pump runs.
+- If the level rises well above the setpoint, the pump stops.
+- High and low alarms become active near the limits.
+
+The HTTP API on `otcell-process01` provides:
+
+- `GET /state`: current process state
+- `GET /health`: service health
+- `POST /command`: apply updates such as `setpoint_pct` or `pump_running`
+
+Example:
+
+```bash
+curl http://198.18.40.20:8080/state
+curl -X POST http://198.18.40.20:8080/command \
+ -H 'Content-Type: application/json' \
+ -d '{"setpoint_pct": 60.0}'
+```
+
+## How The PLC Simulation Works
+
+The PLC simulator is a small Modbus/TCP service.
+
+- It polls the process state API every two seconds.
+- It writes those values into an internal register bank.
+- It marks a health register to show whether the upstream process poll succeeded.
+- It exposes the register bank over Modbus function code `3`.
+- It accepts selected Modbus function code `6` writes and forwards them to the process API.
+
+State is persisted to:
+
+- `/var/lib/ics-plc/state.json`
+
+Useful live checks:
+
+```bash
+incus exec --project ICS-simulation otcell-plc01 -- systemctl status ics-plc
+incus exec --project ICS-simulation otcell-plc01 -- cat /var/lib/ics-plc/state.json
+```
+
+## How The SCADA Service Works
+
+The SCADA service is a poller plus a small HTTP API.
+
+- It opens a TCP connection to the PLC every polling cycle.
+- It reads holding registers from the PLC.
+- It scales the raw register values into named tags.
+- It publishes the current tag snapshot at `/api/tags`.
+- It records whether an optional MQTT broker is reachable.
+
+The SCADA API provides:
+
+- `GET /api/tags`
+- `GET /`
+- `GET /health`
+
+Example:
+
+```bash
+curl http://198.18.30.10:8081/api/tags
+```
+
+## How The HMI Works
+
+The HMI is intentionally thin.
+
+- The root page `/` serves a static HTML dashboard.
+- The page fetches `/api/tags` every two seconds.
+- The HMI server then fetches data from the SCADA API.
+- Cards are colored to make healthy values and alarm conditions easier to spot.
+
+Example:
+
+```bash
+curl http://198.18.30.20:8080/
+curl http://198.18.30.20:8080/api/tags
+```
+
+## How The Activity Simulation Works
+
+`it-activity01` contains a sample scenario runner for repeatable validation steps. It is not a scheduler by default. It runs when you execute it.
+
+The sample scenario currently does three things:
+
+1. confirms that IT can SSH to the jump host
+2. confirms that IT cannot directly reach the PLC
+3. sleeps for two seconds
+
+Run it like this:
+
+```bash
+incus exec --project ICS-simulation it-activity01 -- \
+ python3 /opt/ics/activity/activity_runner.py
+```
+
+Or point it at another scenario:
+
+```bash
+incus exec --project ICS-simulation it-activity01 -- \
+ python3 /opt/ics/activity/activity_runner.py --scenario /path/to/scenario.yaml
+```
+
+Supported sample actions are:
+
+- `sleep`
+- `tcp_check`
+- `http_get`
+
+This makes the activity runner useful for:
+
+- smoke-style validation
+- repeatable classroom demos
+- generating simple known-good traffic before a packet capture
+
+## Deploy The Lab
+
+### Prerequisites
+
+- Incus installed and initialized
+- OpenTofu available as `tofu`
+- Ansible available as `ansible-playbook`
+- Python 3 available as `python3`
+- an existing Incus storage pool
+- an SSH public key for the `lab` user
+
+### Required Variables
+
+Provide these OpenTofu variables through `infra/terraform.tfvars`, environment variables, or `-var` flags:
+
+- `storage_pool`
+- `image_debian_cloud`
+- `image_ubuntu_cloud`
+- `ssh_public_key`
+
+The base deployment uses the Debian cloud image for all system containers. The Ubuntu image variable is present for optional extensions and OCI wrapper patterns.
+
+### End-To-End Deployment
+
+From the repository root:
+
+```bash
+cd infra
+tofu init
+tofu apply
+tofu output -json > ../infra_outputs.json
+cd ..
+
+python3 scripts/generate_inventory.py
+
+cd ansible
+ansible-playbook site.yml
+cd ..
+
+bash scripts/oci_deploy.sh
+bash scripts/smoke_test.sh
+```
+
+Recommended rule:
+
+- treat OpenTofu as infrastructure provisioning
+- treat Ansible as guest convergence
+- re-run Ansible after recreating or replacing containers
+
+## How To Use The Lab
+
+### Enter A Container
+
+```bash
+incus exec --project ICS-simulation it-ws01 -- bash
+incus exec --project ICS-simulation otops-scada01 -- bash
+incus exec --project ICS-simulation otcell-plc01 -- bash
+```
+
+### SSH Into A Host
+
+```bash
+ssh lab@198.18.10.20
+ssh lab@198.18.20.10
+ssh lab@198.18.30.10
+```
+
+### Validate Segmentation
+
+Expected examples:
+
+```bash
+incus exec --project ICS-simulation it-ws01 -- nc -zvw3 198.18.20.10 22
+incus exec --project ICS-simulation it-ws01 -- nc -zvw3 198.18.40.10 502
+incus exec --project ICS-simulation otops-scada01 -- nc -zvw3 198.18.40.10 502
+incus exec --project ICS-simulation wan-test01 -- nc -zvw3 198.18.10.20 22
+```
+
+Expected outcome:
+
+- IT can reach DMZ SSH
+- IT cannot directly reach the PLC
+- SCADA can reach the PLC
+- WAN cannot initiate traffic toward internal zones
+
+### Inspect The Application Layer
+
+```bash
+curl http://198.18.40.20:8080/state
+curl http://198.18.30.10:8081/api/tags
+curl http://198.18.30.20:8080/
+```
+
+### Observe Service State
+
+```bash
+incus exec --project ICS-simulation otcell-process01 -- systemctl status ics-process
+incus exec --project ICS-simulation otcell-plc01 -- systemctl status ics-plc
+incus exec --project ICS-simulation otops-scada01 -- systemctl status ics-scada
+incus exec --project ICS-simulation otops-hmi01 -- systemctl status ics-hmi
+```
+
+### Capture Packets
+
+Good capture points depend on your question:
+
+- `fw01`: best single vantage point for routed inter-zone traffic
+- `otops-scada01`: best place to observe SCADA-to-PLC Modbus sessions
+- `otcell-plc01` or `otcell-process01`: best place to observe local HTTP traffic inside the cell
+- `it-ws01`: useful for validating IT-side reachability and SMB traffic
+- `wan-test01`: useful for validating blocked inbound traffic from the WAN side
+
+Examples:
+
+```bash
+incus exec --project ICS-simulation fw01 -- tcpdump -ni eth4 tcp port 502
+incus exec --project ICS-simulation otcell-plc01 -- tcpdump -ni eth0 host 198.18.40.20
+```
+
+If you replace a container and have not re-run Ansible yet, packet capture tools may not be present on that host.
+
+## Validation And Troubleshooting
+
+### Smoke Test
+
+The bundled smoke test checks:
+
+- IT -> DMZ SSH is allowed
+- IT -> CELL `502` is blocked
+- OPS -> CELL `502` is allowed
+- optional MQTT reachability if `mqtt01` exists
+- SCADA API availability
+- HMI page availability
+
+Run it with:
+
+```bash
+bash scripts/smoke_test.sh
+```
+
+### Common Operator Checks
+
+```bash
+incus list --project ICS-simulation
+incus exec --project ICS-simulation fw01 -- sudo nft list ruleset
+incus exec --project ICS-simulation fw01 -- ip -br a
+incus exec --project ICS-simulation otops-scada01 -- ss -ltnp
+incus exec --project ICS-simulation otcell-process01 -- curl -s http://127.0.0.1:8080/state
+```
+
+### Re-Apply Guest Configuration
+
+If you change Ansible files or recreate a host:
+
+```bash
+cd ansible
+ansible-playbook site.yml
+```
+
+## Repository Structure
+
+This repository is small enough that every top-level directory has a clear purpose.
+
+### `infra/`
+
+OpenTofu code that creates the project, bridges, profile, and instances.
+
+- `main.tf`: creates the Incus project, bridges, base profile, `fw01`, and the remaining lab nodes
+- `locals.tf`: defines the topology, host inventory, static IPs, packages, and cloud-init content
+- `variables.tf`: declares the required inputs
+- `versions.tf`: pins Terraform and provider requirements
+- `outputs.tf`: exports project name and host metadata
+- `terraform.tfvars`: local variable values for the deployment
+- `templates/cloud-init-network-config.tftpl`: renders static interface config
+- `templates/cloud-init-user-data.tftpl`: renders users, packages, files, and boot commands
+
+### `ansible/`
+
+Guest configuration and simulated service deployment.
+
+- `site.yml`: main playbook
+- `ansible.cfg`: inventory path, roles path, and SSH defaults
+- `group_vars/all.yml`: shared service endpoints, addresses, and port values
+- `inventory/inventory.yml`: generated inventory file created by `scripts/generate_inventory.py`
+
+#### `ansible/roles/`
+
+Each role manages one functional part of the lab.
+
+- `router_firewall/`: `nftables`, `dnsmasq`, IP forwarding
+- `file_server/`: Samba shares and configuration
+- `activity_agent/`: sample scenario and runner
+- `process_sim/`: tank process simulator and systemd unit
+- `plc_sim/`: PLC simulator and systemd unit
+- `scada_host/`: SCADA poller/API and systemd unit
+- `hmi_host/`: HMI server and systemd unit
+
+Inside each role:
+
+- `tasks/main.yml` defines the install and converge steps
+- `files/` contains shipped scripts or static assets
+- `templates/` contains Jinja templates for service units and configs
+
+### `scripts/`
+
+Helper tooling for provisioning and validation.
+
+- `generate_inventory.py`: converts `infra_outputs.json` into `ansible/inventory/inventory.yml`
+- `wait_for_cloud_init.sh`: blocks until cloud-init is ready on a target instance
+- `smoke_test.sh`: validates the expected basic connectivity and service behavior
+- `oci_deploy.sh`: launches optional OCI app containers into the lab
+- `oci-wrapper.sh`: helper entrypoint for OCI images that need static IP or gateway setup
+
+## How The Tools Fit Together
+
+The expected workflow is:
+
+1. use OpenTofu to create the Incus project, networks, and containers
+2. export OpenTofu outputs
+3. generate the Ansible inventory from those outputs
+4. run Ansible to converge the guest operating systems and services
+5. optionally deploy OCI add-ons
+6. validate the result with smoke tests and manual checks
+
+In short:
+
+- Incus provides the container runtime and bridges
+- OpenTofu describes infrastructure state
+- Ansible describes guest configuration state
+- Python service files implement the simulated ICS behavior
+- shell and Python helper scripts glue the workflow together
+
+## Notes
+
+- All system containers use cloud-init.
+- A `lab` user is created with passwordless sudo and the supplied SSH key.
+- Static addressing is defined through cloud-init network config.
+- The deployment is intended to be re-runnable.
+- If you replace a container with OpenTofu, re-run Ansible so host tooling and services are restored to the expected state.
+
+## Safety Notice
+
+This lab deliberately simplifies industrial protocols, host behavior, and segmentation details to stay understandable and portable. Use it for research and education, not as a blueprint for production control systems.
diff --git a/labs/ICS-Simulation/ansible/ansible.cfg b/labs/ICS-Simulation/ansible/ansible.cfg
new file mode 100644
index 0000000..9942652
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/ansible.cfg
@@ -0,0 +1,9 @@
+[defaults]
+inventory = inventory/inventory.yml
+roles_path = roles
+host_key_checking = False
+retry_files_enabled = False
+interpreter_python = auto_silent
+
+[ssh_connection]
+pipelining = True
diff --git a/labs/ICS-Simulation/ansible/group_vars/all.yml b/labs/ICS-Simulation/ansible/group_vars/all.yml
new file mode 100644
index 0000000..98392de
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/group_vars/all.yml
@@ -0,0 +1,25 @@
+---
+ansible_user: lab
+ansible_become: true
+
+ics_router_ipv4: 198.18.110.254
+ics_router_dns_upstreams:
+ - 1.1.1.1
+ - 9.9.9.9
+
+ics_process_api_url: http://198.18.40.20:8080
+ics_process_state_url: http://198.18.40.20:8080/state
+ics_process_command_url: http://198.18.40.20:8080/command
+
+ics_plc_host: 198.18.40.10
+ics_plc_port: 502
+
+ics_scada_api_url: http://198.18.30.10:8081
+ics_scada_api_port: 8081
+ics_scada_poll_interval: 2.0
+
+ics_hmi_bind: 0.0.0.0
+ics_hmi_port: 8080
+
+ics_mqtt_host: "{{ lookup('env', 'MQTT_HOST') | default('', true) }}"
+ics_mqtt_port: 1883
diff --git a/labs/ICS-Simulation/ansible/roles/activity_agent/files/__pycache__/activity_runner.cpython-312.pyc b/labs/ICS-Simulation/ansible/roles/activity_agent/files/__pycache__/activity_runner.cpython-312.pyc
new file mode 100644
index 0000000..1a668d3
Binary files /dev/null and b/labs/ICS-Simulation/ansible/roles/activity_agent/files/__pycache__/activity_runner.cpython-312.pyc differ
diff --git a/labs/ICS-Simulation/ansible/roles/activity_agent/files/activity_runner.py b/labs/ICS-Simulation/ansible/roles/activity_agent/files/activity_runner.py
new file mode 100644
index 0000000..35cdb97
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/activity_agent/files/activity_runner.py
@@ -0,0 +1,95 @@
+#!/usr/bin/env python3
+
+import argparse
+import http.client
+import socket
+import sys
+import time
+from pathlib import Path
+
+import yaml
+
+
+def tcp_check(host: str, port: int, timeout: float) -> bool:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
+ sock.settimeout(timeout)
+ return sock.connect_ex((host, port)) == 0
+
+
+def http_get(url: str, timeout: float) -> bool:
+ if not url.startswith("http://"):
+ raise ValueError(f"Only plain HTTP URLs are supported by the sample runner: {url}")
+
+ without_scheme = url[len("http://") :]
+ host_port, _, path = without_scheme.partition("/")
+ if ":" in host_port:
+ host, port_text = host_port.split(":", 1)
+ port = int(port_text)
+ else:
+ host = host_port
+ port = 80
+
+ conn = http.client.HTTPConnection(host, port, timeout=timeout)
+ try:
+ conn.request("GET", "/" + path)
+ response = conn.getresponse()
+ return 200 <= response.status < 400
+ finally:
+ conn.close()
+
+
+def run_step(step: dict, timeout: float) -> bool:
+ action = step["action"]
+ name = step.get("name", action)
+
+ if action == "sleep":
+ seconds = float(step.get("seconds", 1))
+ print(f"[INFO] {name}: sleeping for {seconds} seconds")
+ time.sleep(seconds)
+ return True
+
+ if action == "tcp_check":
+ is_open = tcp_check(step["host"], int(step["port"]), timeout)
+ expect = step.get("expect", "open")
+ expected_open = expect == "open"
+ print(f"[INFO] {name}: tcp {step['host']}:{step['port']} open={is_open} expected={expect}")
+ return is_open == expected_open
+
+ if action == "http_get":
+ ok = http_get(step["url"], timeout)
+ print(f"[INFO] {name}: http_get {step['url']} ok={ok}")
+ return ok
+
+ raise ValueError(f"Unsupported action {action}")
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Run a simple ICS lab activity scenario.")
+ parser.add_argument(
+ "--scenario",
+ default="/opt/ics/activity/scenario.yaml",
+ help="Path to the YAML scenario file.",
+ )
+ parser.add_argument(
+ "--timeout",
+ type=float,
+ default=3.0,
+ help="Network timeout in seconds for active checks.",
+ )
+ args = parser.parse_args()
+
+ scenario_path = Path(args.scenario)
+ payload = yaml.safe_load(scenario_path.read_text()) or {}
+ steps = payload.get("steps", [])
+
+ for step in steps:
+ if not run_step(step, args.timeout):
+ print(f"[ERROR] Scenario step failed: {step.get('name', step.get('action'))}", file=sys.stderr)
+ return 1
+
+ print("[INFO] Scenario completed successfully")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/labs/ICS-Simulation/ansible/roles/activity_agent/files/scenario.yaml b/labs/ICS-Simulation/ansible/roles/activity_agent/files/scenario.yaml
new file mode 100644
index 0000000..780398d
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/activity_agent/files/scenario.yaml
@@ -0,0 +1,16 @@
+steps:
+ - name: validate_jump_host_ssh
+ action: tcp_check
+ host: 198.18.20.10
+ port: 22
+ expect: open
+
+ - name: confirm_direct_cell_access_is_blocked
+ action: tcp_check
+ host: 198.18.40.10
+ port: 502
+ expect: closed
+
+ - name: pause_between_steps
+ action: sleep
+ seconds: 2
diff --git a/labs/ICS-Simulation/ansible/roles/activity_agent/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/activity_agent/tasks/main.yml
new file mode 100644
index 0000000..94f32c5
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/activity_agent/tasks/main.yml
@@ -0,0 +1,33 @@
+---
+- name: Install activity runner dependencies
+ ansible.builtin.apt:
+ name:
+ - openssh-client
+ - python3-yaml
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: Create activity runner directory
+ ansible.builtin.file:
+ path: /opt/ics/activity
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+
+- name: Install scenario definition
+ ansible.builtin.copy:
+ src: scenario.yaml
+ dest: /opt/ics/activity/scenario.yaml
+ owner: root
+ group: root
+ mode: "0644"
+
+- name: Install activity runner
+ ansible.builtin.copy:
+ src: activity_runner.py
+ dest: /opt/ics/activity/activity_runner.py
+ owner: root
+ group: root
+ mode: "0755"
diff --git a/labs/ICS-Simulation/ansible/roles/file_server/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/file_server/tasks/main.yml
new file mode 100644
index 0000000..6909efd
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/file_server/tasks/main.yml
@@ -0,0 +1,41 @@
+---
+- name: Install Samba
+ ansible.builtin.apt:
+ name:
+ - samba
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: Ensure Samba share directories exist
+ ansible.builtin.file:
+ path: "/srv/ics-shares/{{ item }}"
+ state: directory
+ owner: lab
+ group: lab
+ mode: "0775"
+ loop:
+ - Engineering
+ - Operations
+ - Backups
+
+- name: Deploy Samba configuration
+ ansible.builtin.template:
+ src: smb.conf.j2
+ dest: /etc/samba/smb.conf
+ owner: root
+ group: root
+ mode: "0644"
+ register: samba_config
+
+- name: Ensure smbd is enabled and started
+ ansible.builtin.service:
+ name: smbd
+ enabled: true
+ state: started
+
+- name: Restart smbd when configuration changes
+ ansible.builtin.service:
+ name: smbd
+ state: restarted
+ when: samba_config.changed
diff --git a/labs/ICS-Simulation/ansible/roles/file_server/templates/smb.conf.j2 b/labs/ICS-Simulation/ansible/roles/file_server/templates/smb.conf.j2
new file mode 100644
index 0000000..9351f6a
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/file_server/templates/smb.conf.j2
@@ -0,0 +1,37 @@
+[global]
+ workgroup = WORKGROUP
+ server string = ICS Simulation File Server
+ security = user
+ map to guest = Bad User
+ guest account = nobody
+ load printers = no
+ printing = bsd
+ disable spoolss = yes
+ server min protocol = SMB2
+
+[Engineering]
+ path = /srv/ics-shares/Engineering
+ browseable = yes
+ read only = no
+ guest ok = yes
+ force user = lab
+ create mask = 0664
+ directory mask = 0775
+
+[Operations]
+ path = /srv/ics-shares/Operations
+ browseable = yes
+ read only = no
+ guest ok = yes
+ force user = lab
+ create mask = 0664
+ directory mask = 0775
+
+[Backups]
+ path = /srv/ics-shares/Backups
+ browseable = yes
+ read only = no
+ guest ok = yes
+ force user = lab
+ create mask = 0664
+ directory mask = 0775
diff --git a/labs/ICS-Simulation/ansible/roles/hmi_host/files/__pycache__/hmi_server.cpython-312.pyc b/labs/ICS-Simulation/ansible/roles/hmi_host/files/__pycache__/hmi_server.cpython-312.pyc
new file mode 100644
index 0000000..9ba3fb4
Binary files /dev/null and b/labs/ICS-Simulation/ansible/roles/hmi_host/files/__pycache__/hmi_server.cpython-312.pyc differ
diff --git a/labs/ICS-Simulation/ansible/roles/hmi_host/files/hmi_server.py b/labs/ICS-Simulation/ansible/roles/hmi_host/files/hmi_server.py
new file mode 100644
index 0000000..75fdef1
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/hmi_host/files/hmi_server.py
@@ -0,0 +1,171 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import urllib.request
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+SCADA_API_URL = os.environ.get("SCADA_API_URL", "http://198.18.30.10:8081/api/tags")
+
+HTML = """
+
+
+
+
+ ICS HMI
+
+
+
+
+ ICS HMI
+ Live view of the simulated tank, driven by the SCADA poller in the OT operations zone.
+
+
+
+
+
+"""
+
+
+def fetch_tags() -> bytes:
+ with urllib.request.urlopen(SCADA_API_URL, timeout=2) as response:
+ return response.read()
+
+
+class Handler(BaseHTTPRequestHandler):
+ def do_GET(self) -> None:
+ if self.path == "/":
+ body = HTML.encode()
+ self.send_response(200)
+ self.send_header("Content-Type", "text/html; charset=utf-8")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ if self.path == "/api/tags":
+ body = fetch_tags()
+ self.send_response(200)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+ return
+
+ self.send_response(404)
+ self.end_headers()
+
+ def log_message(self, fmt: str, *args) -> None:
+ return
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Simple ICS HMI server.")
+ parser.add_argument("--listen", default="0.0.0.0")
+ parser.add_argument("--port", type=int, default=8080)
+ args = parser.parse_args()
+
+ server = ThreadingHTTPServer((args.listen, args.port), Handler)
+ server.serve_forever()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/labs/ICS-Simulation/ansible/roles/hmi_host/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/hmi_host/tasks/main.yml
new file mode 100644
index 0000000..450ddda
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/hmi_host/tasks/main.yml
@@ -0,0 +1,43 @@
+---
+- name: Create HMI directories
+ ansible.builtin.file:
+ path: /opt/ics/hmi
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+
+- name: Install HMI service
+ ansible.builtin.copy:
+ src: hmi_server.py
+ dest: /opt/ics/hmi/hmi_server.py
+ owner: root
+ group: root
+ mode: "0755"
+ register: hmi_script
+
+- name: Install HMI systemd unit
+ ansible.builtin.template:
+ src: ics-hmi.service.j2
+ dest: /etc/systemd/system/ics-hmi.service
+ owner: root
+ group: root
+ mode: "0644"
+ register: hmi_unit
+
+- name: Reload systemd after HMI unit changes
+ ansible.builtin.systemd:
+ daemon_reload: true
+ when: hmi_unit.changed
+
+- name: Ensure HMI service is enabled and started
+ ansible.builtin.systemd:
+ name: ics-hmi.service
+ enabled: true
+ state: started
+
+- name: Restart HMI when code or unit changes
+ ansible.builtin.systemd:
+ name: ics-hmi.service
+ state: restarted
+ when: hmi_script.changed or hmi_unit.changed
diff --git a/labs/ICS-Simulation/ansible/roles/hmi_host/templates/ics-hmi.service.j2 b/labs/ICS-Simulation/ansible/roles/hmi_host/templates/ics-hmi.service.j2
new file mode 100644
index 0000000..3fd5431
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/hmi_host/templates/ics-hmi.service.j2
@@ -0,0 +1,14 @@
+[Unit]
+Description=ICS HMI web UI
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+Environment=SCADA_API_URL={{ ics_scada_api_url }}/api/tags
+ExecStart=/usr/bin/python3 /opt/ics/hmi/hmi_server.py --listen {{ ics_hmi_bind }} --port {{ ics_hmi_port }}
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/labs/ICS-Simulation/ansible/roles/plc_sim/files/__pycache__/plc_sim.cpython-312.pyc b/labs/ICS-Simulation/ansible/roles/plc_sim/files/__pycache__/plc_sim.cpython-312.pyc
new file mode 100644
index 0000000..0032676
Binary files /dev/null and b/labs/ICS-Simulation/ansible/roles/plc_sim/files/__pycache__/plc_sim.cpython-312.pyc differ
diff --git a/labs/ICS-Simulation/ansible/roles/plc_sim/files/plc_sim.py b/labs/ICS-Simulation/ansible/roles/plc_sim/files/plc_sim.py
new file mode 100644
index 0000000..84e9f32
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/plc_sim/files/plc_sim.py
@@ -0,0 +1,130 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import socket
+import struct
+import threading
+import time
+import urllib.request
+from pathlib import Path
+
+
+PROCESS_STATE_URL = os.environ.get("PROCESS_STATE_URL", "http://198.18.40.20:8080/state")
+PROCESS_COMMAND_URL = os.environ.get("PROCESS_COMMAND_URL", "http://198.18.40.20:8080/command")
+
+
+class RegisterBank:
+ def __init__(self, state_file: Path) -> None:
+ self.state_file = state_file
+ self.lock = threading.Lock()
+ self.registers = [0] * 16
+
+ def _write_state(self) -> None:
+ payload = {
+ "registers": self.registers,
+ "updated_at": time.time(),
+ }
+ self.state_file.parent.mkdir(parents=True, exist_ok=True)
+ self.state_file.write_text(json.dumps(payload, indent=2))
+
+ def update_from_process(self) -> None:
+ while True:
+ try:
+ with urllib.request.urlopen(PROCESS_STATE_URL, timeout=2) as response:
+ payload = json.loads(response.read().decode())
+ with self.lock:
+ self.registers[0] = int(round(float(payload["tank_level_pct"]) * 10))
+ self.registers[1] = int(round(float(payload["setpoint_pct"]) * 10))
+ self.registers[2] = int(round(float(payload["inlet_flow_lpm"]) * 10))
+ self.registers[3] = int(round(float(payload["outlet_flow_lpm"]) * 10))
+ self.registers[4] = int(payload["pump_running"])
+ self.registers[5] = int(payload["alarm_high"])
+ self.registers[6] = int(payload["alarm_low"])
+ self.registers[7] = 1
+ self._write_state()
+ except Exception:
+ with self.lock:
+ self.registers[7] = 0
+ self._write_state()
+ time.sleep(2.0)
+
+ def read(self, start: int, quantity: int) -> list[int]:
+ with self.lock:
+ return self.registers[start : start + quantity]
+
+ def write_single(self, address: int, value: int) -> None:
+ payload = {}
+ if address == 1:
+ payload["setpoint_pct"] = value / 10.0
+ elif address == 4:
+ payload["pump_running"] = 1 if value else 0
+ else:
+ return
+
+ data = json.dumps(payload).encode()
+ req = urllib.request.Request(
+ PROCESS_COMMAND_URL,
+ data=data,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=2):
+ pass
+
+
+def handle_client(connection: socket.socket, bank: RegisterBank) -> None:
+ with connection:
+ request = connection.recv(260)
+ if len(request) < 12:
+ return
+
+ transaction_id, protocol_id, length = struct.unpack(">HHH", request[:6])
+ unit_id = request[6]
+ function_code = request[7]
+
+ if protocol_id != 0:
+ return
+
+ if function_code == 3:
+ start_address, quantity = struct.unpack(">HH", request[8:12])
+ values = bank.read(start_address, quantity)
+ data = b"".join(struct.pack(">H", value) for value in values)
+ pdu = bytes([function_code, len(data)]) + data
+ elif function_code == 6:
+ address, value = struct.unpack(">HH", request[8:12])
+ bank.write_single(address, value)
+ pdu = bytes([function_code]) + struct.pack(">HH", address, value)
+ else:
+ pdu = bytes([function_code | 0x80, 0x01])
+
+ response = struct.pack(">HHHB", transaction_id, 0, len(pdu) + 1, unit_id) + pdu
+ connection.sendall(response)
+
+
+def serve(listen: str, port: int, bank: RegisterBank) -> None:
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as server:
+ server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
+ server.bind((listen, port))
+ server.listen(5)
+ while True:
+ connection, _ = server.accept()
+ threading.Thread(target=handle_client, args=(connection, bank), daemon=True).start()
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Minimal Modbus/TCP PLC simulator.")
+ parser.add_argument("--listen", default="0.0.0.0")
+ parser.add_argument("--port", type=int, default=502)
+ parser.add_argument("--state-file", default="/var/lib/ics-plc/state.json")
+ args = parser.parse_args()
+
+ bank = RegisterBank(Path(args.state_file))
+ threading.Thread(target=bank.update_from_process, daemon=True).start()
+ serve(args.listen, args.port, bank)
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/labs/ICS-Simulation/ansible/roles/plc_sim/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/plc_sim/tasks/main.yml
new file mode 100644
index 0000000..3837603
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/plc_sim/tasks/main.yml
@@ -0,0 +1,46 @@
+---
+- name: Create PLC simulator directories
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+ loop:
+ - /opt/ics/plc
+ - /var/lib/ics-plc
+
+- name: Install PLC simulator
+ ansible.builtin.copy:
+ src: plc_sim.py
+ dest: /opt/ics/plc/plc_sim.py
+ owner: root
+ group: root
+ mode: "0755"
+ register: plc_sim_script
+
+- name: Install PLC simulator systemd unit
+ ansible.builtin.template:
+ src: ics-plc.service.j2
+ dest: /etc/systemd/system/ics-plc.service
+ owner: root
+ group: root
+ mode: "0644"
+ register: plc_sim_unit
+
+- name: Reload systemd after PLC simulator unit changes
+ ansible.builtin.systemd:
+ daemon_reload: true
+ when: plc_sim_unit.changed
+
+- name: Ensure PLC simulator service is enabled and started
+ ansible.builtin.systemd:
+ name: ics-plc.service
+ enabled: true
+ state: started
+
+- name: Restart PLC simulator when code or unit changes
+ ansible.builtin.systemd:
+ name: ics-plc.service
+ state: restarted
+ when: plc_sim_script.changed or plc_sim_unit.changed
diff --git a/labs/ICS-Simulation/ansible/roles/plc_sim/templates/ics-plc.service.j2 b/labs/ICS-Simulation/ansible/roles/plc_sim/templates/ics-plc.service.j2
new file mode 100644
index 0000000..becaa60
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/plc_sim/templates/ics-plc.service.j2
@@ -0,0 +1,15 @@
+[Unit]
+Description=ICS PLC Modbus/TCP simulator
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+Environment=PROCESS_STATE_URL={{ ics_process_state_url }}
+Environment=PROCESS_COMMAND_URL={{ ics_process_command_url }}
+ExecStart=/usr/bin/python3 /opt/ics/plc/plc_sim.py --listen 0.0.0.0 --port {{ ics_plc_port }} --state-file /var/lib/ics-plc/state.json
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/labs/ICS-Simulation/ansible/roles/process_sim/files/__pycache__/tank_process.cpython-312.pyc b/labs/ICS-Simulation/ansible/roles/process_sim/files/__pycache__/tank_process.cpython-312.pyc
new file mode 100644
index 0000000..ae496d5
Binary files /dev/null and b/labs/ICS-Simulation/ansible/roles/process_sim/files/__pycache__/tank_process.cpython-312.pyc differ
diff --git a/labs/ICS-Simulation/ansible/roles/process_sim/files/tank_process.py b/labs/ICS-Simulation/ansible/roles/process_sim/files/tank_process.py
new file mode 100644
index 0000000..531b10a
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/process_sim/files/tank_process.py
@@ -0,0 +1,129 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+from pathlib import Path
+
+
+class ProcessState:
+ def __init__(self, state_file: Path) -> None:
+ self.state_file = state_file
+ self.lock = threading.Lock()
+ self.state = {
+ "tank_level_pct": 52.0,
+ "setpoint_pct": 55.0,
+ "inlet_flow_lpm": 12.0,
+ "outlet_flow_lpm": 9.0,
+ "pump_running": 1,
+ "alarm_high": 0,
+ "alarm_low": 0,
+ "updated_at": time.time(),
+ }
+
+ def snapshot(self) -> dict:
+ with self.lock:
+ return dict(self.state)
+
+ def apply_command(self, payload: dict) -> dict:
+ with self.lock:
+ if "setpoint_pct" in payload:
+ self.state["setpoint_pct"] = max(5.0, min(95.0, float(payload["setpoint_pct"])))
+ if "pump_running" in payload:
+ self.state["pump_running"] = 1 if int(payload["pump_running"]) else 0
+ self.state["updated_at"] = time.time()
+ return dict(self.state)
+
+ def tick(self) -> None:
+ while True:
+ with self.lock:
+ level = self.state["tank_level_pct"]
+ setpoint = self.state["setpoint_pct"]
+ pump_running = self.state["pump_running"]
+
+ if level < setpoint - 3:
+ pump_running = 1
+ elif level > setpoint + 3:
+ pump_running = 0
+
+ inlet_flow = 14.0 if pump_running else 2.0
+ outlet_flow = 9.0 + (level / 100.0) * 4.0
+ level += (inlet_flow - outlet_flow) / 30.0
+ level = max(0.0, min(100.0, level))
+
+ self.state.update(
+ {
+ "tank_level_pct": round(level, 2),
+ "inlet_flow_lpm": round(inlet_flow, 2),
+ "outlet_flow_lpm": round(outlet_flow, 2),
+ "pump_running": pump_running,
+ "alarm_high": 1 if level >= 85 else 0,
+ "alarm_low": 1 if level <= 15 else 0,
+ "updated_at": time.time(),
+ }
+ )
+ self.state_file.write_text(json.dumps(self.state, indent=2))
+
+ time.sleep(1.0)
+
+
+def make_handler(process_state: ProcessState):
+ class Handler(BaseHTTPRequestHandler):
+ def _write_json(self, status_code: int, payload: dict) -> None:
+ body = json.dumps(payload).encode()
+ self.send_response(status_code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None:
+ if self.path in {"/", "/state"}:
+ self._write_json(200, process_state.snapshot())
+ return
+
+ if self.path == "/health":
+ self._write_json(200, {"status": "ok"})
+ return
+
+ self._write_json(404, {"error": "not_found"})
+
+ def do_POST(self) -> None:
+ if self.path != "/command":
+ self._write_json(404, {"error": "not_found"})
+ return
+
+ length = int(self.headers.get("Content-Length", "0"))
+ body = self.rfile.read(length).decode() if length else "{}"
+ payload = json.loads(body or "{}")
+ self._write_json(200, process_state.apply_command(payload))
+
+ def log_message(self, fmt: str, *args) -> None:
+ return
+
+ return Handler
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Simple tank process simulator.")
+ parser.add_argument("--listen", default="0.0.0.0")
+ parser.add_argument("--port", type=int, default=8080)
+ parser.add_argument("--state-file", default="/var/lib/ics-process/state.json")
+ args = parser.parse_args()
+
+ state = ProcessState(Path(args.state_file))
+ state.state_file.parent.mkdir(parents=True, exist_ok=True)
+ state.state_file.write_text(json.dumps(state.snapshot(), indent=2))
+
+ worker = threading.Thread(target=state.tick, daemon=True)
+ worker.start()
+
+ server = ThreadingHTTPServer((args.listen, args.port), make_handler(state))
+ server.serve_forever()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/labs/ICS-Simulation/ansible/roles/process_sim/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/process_sim/tasks/main.yml
new file mode 100644
index 0000000..0d9355c
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/process_sim/tasks/main.yml
@@ -0,0 +1,46 @@
+---
+- name: Create process simulator directories
+ ansible.builtin.file:
+ path: "{{ item }}"
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+ loop:
+ - /opt/ics/process
+ - /var/lib/ics-process
+
+- name: Install process simulator
+ ansible.builtin.copy:
+ src: tank_process.py
+ dest: /opt/ics/process/tank_process.py
+ owner: root
+ group: root
+ mode: "0755"
+ register: process_sim_script
+
+- name: Install process simulator systemd unit
+ ansible.builtin.template:
+ src: ics-process.service.j2
+ dest: /etc/systemd/system/ics-process.service
+ owner: root
+ group: root
+ mode: "0644"
+ register: process_sim_unit
+
+- name: Reload systemd after process simulator unit changes
+ ansible.builtin.systemd:
+ daemon_reload: true
+ when: process_sim_unit.changed
+
+- name: Ensure process simulator service is enabled and started
+ ansible.builtin.systemd:
+ name: ics-process.service
+ enabled: true
+ state: started
+
+- name: Restart process simulator when code or unit changes
+ ansible.builtin.systemd:
+ name: ics-process.service
+ state: restarted
+ when: process_sim_script.changed or process_sim_unit.changed
diff --git a/labs/ICS-Simulation/ansible/roles/process_sim/templates/ics-process.service.j2 b/labs/ICS-Simulation/ansible/roles/process_sim/templates/ics-process.service.j2
new file mode 100644
index 0000000..e565967
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/process_sim/templates/ics-process.service.j2
@@ -0,0 +1,13 @@
+[Unit]
+Description=ICS tank process simulator
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+ExecStart=/usr/bin/python3 /opt/ics/process/tank_process.py --listen 0.0.0.0 --port 8080 --state-file /var/lib/ics-process/state.json
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/labs/ICS-Simulation/ansible/roles/router_firewall/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/router_firewall/tasks/main.yml
new file mode 100644
index 0000000..9ba37b8
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/router_firewall/tasks/main.yml
@@ -0,0 +1,59 @@
+---
+- name: Install router packages
+ ansible.builtin.apt:
+ name:
+ - dnsmasq
+ - nftables
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: Enable IPv4 forwarding
+ ansible.builtin.sysctl:
+ name: net.ipv4.ip_forward
+ value: "1"
+ state: present
+ sysctl_file: /etc/sysctl.d/99-ics-router.conf
+ reload: true
+
+- name: Deploy nftables policy
+ ansible.builtin.template:
+ src: nftables.conf.j2
+ dest: /etc/nftables.conf
+ owner: root
+ group: root
+ mode: "0644"
+ register: router_nftables
+
+- name: Deploy dnsmasq forwarding config
+ ansible.builtin.template:
+ src: ics-lab-dnsmasq.conf.j2
+ dest: /etc/dnsmasq.d/ics-lab.conf
+ owner: root
+ group: root
+ mode: "0644"
+ register: router_dnsmasq
+
+- name: Ensure nftables is enabled and started
+ ansible.builtin.service:
+ name: nftables
+ enabled: true
+ state: started
+
+- name: Restart nftables when the policy changes
+ ansible.builtin.service:
+ name: nftables
+ state: restarted
+ when: router_nftables.changed
+
+- name: Ensure dnsmasq is enabled and started
+ ansible.builtin.service:
+ name: dnsmasq
+ enabled: true
+ state: started
+
+- name: Restart dnsmasq when the config changes
+ ansible.builtin.service:
+ name: dnsmasq
+ state: restarted
+ when: router_dnsmasq.changed
diff --git a/labs/ICS-Simulation/ansible/roles/router_firewall/templates/ics-lab-dnsmasq.conf.j2 b/labs/ICS-Simulation/ansible/roles/router_firewall/templates/ics-lab-dnsmasq.conf.j2
new file mode 100644
index 0000000..11b3387
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/router_firewall/templates/ics-lab-dnsmasq.conf.j2
@@ -0,0 +1,21 @@
+domain-needed
+bogus-priv
+bind-interfaces
+cache-size=1000
+no-resolv
+
+interface=eth0
+interface=eth1
+interface=eth2
+interface=eth3
+interface=eth4
+
+listen-address=198.18.110.254
+listen-address=198.18.10.254
+listen-address=198.18.20.254
+listen-address=198.18.30.254
+listen-address=198.18.40.254
+
+{% for upstream in ics_router_dns_upstreams %}
+server={{ upstream }}
+{% endfor %}
diff --git a/labs/ICS-Simulation/ansible/roles/router_firewall/templates/nftables.conf.j2 b/labs/ICS-Simulation/ansible/roles/router_firewall/templates/nftables.conf.j2
new file mode 100644
index 0000000..36c42e3
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/router_firewall/templates/nftables.conf.j2
@@ -0,0 +1,58 @@
+#!/usr/sbin/nft -f
+
+flush ruleset
+
+define wan_if = "eth0"
+define it_if = "eth1"
+define dmz_if = "eth2"
+define ops_if = "eth3"
+define cell_if = "eth4"
+
+table inet filter {
+ chain input {
+ type filter hook input priority filter; policy drop;
+
+ iif "lo" accept
+ ct state established,related accept
+ ct state invalid drop
+
+ ip protocol icmp accept
+ ip6 nexthdr ipv6-icmp accept
+
+ # Management and resolver access to the router itself.
+ tcp dport 22 accept
+ udp dport 53 accept
+ tcp dport 53 accept
+ }
+
+ chain forward {
+ type filter hook forward priority filter; policy drop;
+
+ ct state established,related accept
+ ct state invalid drop
+
+ # Internal zone egress to WAN is allowed and masqueraded in the nat table.
+ iifname { $it_if, $dmz_if, $ops_if, $cell_if } oifname $wan_if accept
+
+ # Allowed cross-zone flows.
+ iifname $it_if oifname $dmz_if tcp dport 22 accept
+ iifname $dmz_if oifname $ops_if tcp dport { 22, 443 } accept
+ iifname $ops_if oifname $cell_if tcp dport 502 accept
+ iifname $ops_if oifname $dmz_if tcp dport 1883 accept
+
+ # Explicitly deny WAN initiated traffic toward internal zones.
+ iifname $wan_if oifname { $it_if, $dmz_if, $ops_if, $cell_if } drop
+ }
+
+ chain output {
+ type filter hook output priority filter; policy accept;
+ }
+}
+
+table ip nat {
+ chain postrouting {
+ type nat hook postrouting priority srcnat; policy accept;
+
+ oifname $wan_if ip saddr { 198.18.10.0/24, 198.18.20.0/24, 198.18.30.0/24, 198.18.40.0/24 } masquerade
+ }
+}
diff --git a/labs/ICS-Simulation/ansible/roles/scada_host/files/__pycache__/scada_api.cpython-312.pyc b/labs/ICS-Simulation/ansible/roles/scada_host/files/__pycache__/scada_api.cpython-312.pyc
new file mode 100644
index 0000000..28b4a7a
Binary files /dev/null and b/labs/ICS-Simulation/ansible/roles/scada_host/files/__pycache__/scada_api.cpython-312.pyc differ
diff --git a/labs/ICS-Simulation/ansible/roles/scada_host/files/scada_api.py b/labs/ICS-Simulation/ansible/roles/scada_host/files/scada_api.py
new file mode 100644
index 0000000..adfad5e
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/scada_host/files/scada_api.py
@@ -0,0 +1,142 @@
+#!/usr/bin/env python3
+
+import argparse
+import json
+import os
+import socket
+import struct
+import threading
+import time
+from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
+
+
+PLC_HOST = os.environ.get("PLC_HOST", "198.18.40.10")
+PLC_PORT = int(os.environ.get("PLC_PORT", "502"))
+POLL_INTERVAL = float(os.environ.get("POLL_INTERVAL", "2"))
+MQTT_HOST = os.environ.get("MQTT_HOST", "")
+MQTT_PORT = int(os.environ.get("MQTT_PORT", "1883"))
+
+TAG_DEFINITIONS = {
+ "tank_level_pct": 0.1,
+ "setpoint_pct": 0.1,
+ "inlet_flow_lpm": 0.1,
+ "outlet_flow_lpm": 0.1,
+ "pump_running": 1.0,
+ "alarm_high": 1.0,
+ "alarm_low": 1.0,
+ "plc_healthy": 1.0,
+}
+
+TAG_NAMES = list(TAG_DEFINITIONS.keys())
+STATE = {
+ "tags": {tag: 0 for tag in TAG_NAMES},
+ "updated_at": 0,
+ "mqtt_reachable": False,
+ "status": "starting",
+}
+STATE_LOCK = threading.Lock()
+
+
+def read_holding_registers(host: str, port: int, start: int, quantity: int) -> list[int]:
+ transaction_id = int(time.time() * 1000) & 0xFFFF
+ pdu = struct.pack(">BHH", 3, start, quantity)
+ request = struct.pack(">HHHB", transaction_id, 0, len(pdu) + 1, 1) + pdu
+
+ with socket.create_connection((host, port), timeout=2) as sock:
+ sock.sendall(request)
+ response = sock.recv(512)
+
+ if len(response) < 9:
+ raise RuntimeError("Short Modbus response")
+
+ function_code = response[7]
+ if function_code & 0x80:
+ raise RuntimeError("Modbus exception response")
+
+ byte_count = response[8]
+ registers = []
+ for offset in range(0, byte_count, 2):
+ registers.append(struct.unpack(">H", response[9 + offset : 11 + offset])[0])
+ return registers
+
+
+def mqtt_reachable() -> bool:
+ if not MQTT_HOST:
+ return False
+
+ try:
+ with socket.create_connection((MQTT_HOST, MQTT_PORT), timeout=2):
+ return True
+ except OSError:
+ return False
+
+
+def poll_loop() -> None:
+ while True:
+ try:
+ registers = read_holding_registers(PLC_HOST, PLC_PORT, 0, len(TAG_NAMES))
+ tags = {}
+ for name, value in zip(TAG_NAMES, registers):
+ scale = TAG_DEFINITIONS[name]
+ if scale == 1.0:
+ tags[name] = int(value)
+ else:
+ tags[name] = round(value * scale, 2)
+
+ with STATE_LOCK:
+ STATE["tags"] = tags
+ STATE["updated_at"] = time.time()
+ STATE["mqtt_reachable"] = mqtt_reachable()
+ STATE["status"] = "ok"
+ except Exception as exc:
+ with STATE_LOCK:
+ STATE["status"] = f"degraded: {exc}"
+ STATE["updated_at"] = time.time()
+ STATE["mqtt_reachable"] = mqtt_reachable()
+ time.sleep(POLL_INTERVAL)
+
+
+def make_handler():
+ class Handler(BaseHTTPRequestHandler):
+ def _write_json(self, status_code: int, payload: dict) -> None:
+ body = json.dumps(payload).encode()
+ self.send_response(status_code)
+ self.send_header("Content-Type", "application/json")
+ self.send_header("Content-Length", str(len(body)))
+ self.end_headers()
+ self.wfile.write(body)
+
+ def do_GET(self) -> None:
+ with STATE_LOCK:
+ state = dict(STATE)
+
+ if self.path == "/health":
+ self._write_json(200, {"status": state["status"]})
+ return
+
+ if self.path in {"/", "/api/tags"}:
+ self._write_json(200, state)
+ return
+
+ self._write_json(404, {"error": "not_found"})
+
+ def log_message(self, fmt: str, *args) -> None:
+ return
+
+ return Handler
+
+
+def main() -> int:
+ parser = argparse.ArgumentParser(description="Simple SCADA API backed by PLC polling.")
+ parser.add_argument("--listen", default="0.0.0.0")
+ parser.add_argument("--port", type=int, default=8081)
+ args = parser.parse_args()
+
+ threading.Thread(target=poll_loop, daemon=True).start()
+ server = ThreadingHTTPServer((args.listen, args.port), make_handler())
+ server.serve_forever()
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/labs/ICS-Simulation/ansible/roles/scada_host/tasks/main.yml b/labs/ICS-Simulation/ansible/roles/scada_host/tasks/main.yml
new file mode 100644
index 0000000..6ece56c
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/scada_host/tasks/main.yml
@@ -0,0 +1,43 @@
+---
+- name: Create SCADA directories
+ ansible.builtin.file:
+ path: /opt/ics/scada
+ state: directory
+ owner: root
+ group: root
+ mode: "0755"
+
+- name: Install SCADA API service
+ ansible.builtin.copy:
+ src: scada_api.py
+ dest: /opt/ics/scada/scada_api.py
+ owner: root
+ group: root
+ mode: "0755"
+ register: scada_api_script
+
+- name: Install SCADA systemd unit
+ ansible.builtin.template:
+ src: ics-scada.service.j2
+ dest: /etc/systemd/system/ics-scada.service
+ owner: root
+ group: root
+ mode: "0644"
+ register: scada_api_unit
+
+- name: Reload systemd after SCADA unit changes
+ ansible.builtin.systemd:
+ daemon_reload: true
+ when: scada_api_unit.changed
+
+- name: Ensure SCADA API service is enabled and started
+ ansible.builtin.systemd:
+ name: ics-scada.service
+ enabled: true
+ state: started
+
+- name: Restart SCADA API service when code or unit changes
+ ansible.builtin.systemd:
+ name: ics-scada.service
+ state: restarted
+ when: scada_api_script.changed or scada_api_unit.changed
diff --git a/labs/ICS-Simulation/ansible/roles/scada_host/templates/ics-scada.service.j2 b/labs/ICS-Simulation/ansible/roles/scada_host/templates/ics-scada.service.j2
new file mode 100644
index 0000000..3b89909
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/roles/scada_host/templates/ics-scada.service.j2
@@ -0,0 +1,18 @@
+[Unit]
+Description=ICS SCADA poller and API
+After=network-online.target
+Wants=network-online.target
+
+[Service]
+Type=simple
+Environment=PLC_HOST={{ ics_plc_host }}
+Environment=PLC_PORT={{ ics_plc_port }}
+Environment=MQTT_HOST={{ ics_mqtt_host }}
+Environment=MQTT_PORT={{ ics_mqtt_port }}
+Environment=POLL_INTERVAL={{ ics_scada_poll_interval }}
+ExecStart=/usr/bin/python3 /opt/ics/scada/scada_api.py --listen 0.0.0.0 --port {{ ics_scada_api_port }}
+Restart=always
+RestartSec=2
+
+[Install]
+WantedBy=multi-user.target
diff --git a/labs/ICS-Simulation/ansible/site.yml b/labs/ICS-Simulation/ansible/site.yml
new file mode 100644
index 0000000..1f69e8a
--- /dev/null
+++ b/labs/ICS-Simulation/ansible/site.yml
@@ -0,0 +1,109 @@
+---
+- name: Baseline packages for all lab nodes
+ hosts: all
+ gather_facts: true
+ become: true
+ tasks:
+ - name: Install common troubleshooting packages
+ ansible.builtin.apt:
+ name:
+ - ca-certificates
+ - curl
+ - jq
+ - netcat-openbsd
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: WAN test host tooling
+ hosts: wan_test
+ gather_facts: false
+ become: true
+ tasks:
+ - name: Install WAN validation tools
+ ansible.builtin.apt:
+ name:
+ - iputils-ping
+ - traceroute
+ - tcpdump
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: IT workstation tooling
+ hosts: it_workstations
+ gather_facts: false
+ become: true
+ tasks:
+ - name: Install IT workstation tools
+ ansible.builtin.apt:
+ name:
+ - iputils-ping
+ - traceroute
+ - tcpdump
+ - smbclient
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: Jump host tooling
+ hosts: jump_hosts
+ gather_facts: false
+ become: true
+ tasks:
+ - name: Install SSH client tools on jump host
+ ansible.builtin.apt:
+ name:
+ - openssh-client
+ state: present
+ update_cache: true
+ cache_valid_time: 3600
+
+- name: Configure router and firewall
+ hosts: router_firewall
+ gather_facts: false
+ become: true
+ roles:
+ - router_firewall
+
+- name: Configure file server
+ hosts: file_servers
+ gather_facts: false
+ become: true
+ roles:
+ - file_server
+
+- name: Install activity runner
+ hosts: activity_agents
+ gather_facts: false
+ become: true
+ roles:
+ - activity_agent
+
+- name: Configure process simulator
+ hosts: process_simulators
+ gather_facts: false
+ become: true
+ roles:
+ - process_sim
+
+- name: Configure PLC simulator
+ hosts: plc
+ gather_facts: false
+ become: true
+ roles:
+ - plc_sim
+
+- name: Configure SCADA poller and API
+ hosts: scada
+ gather_facts: false
+ become: true
+ roles:
+ - scada_host
+
+- name: Configure HMI
+ hosts: hmi
+ gather_facts: false
+ become: true
+ roles:
+ - hmi_host
diff --git a/labs/ICS-Simulation/infra/locals.tf b/labs/ICS-Simulation/infra/locals.tf
new file mode 100644
index 0000000..13c2816
--- /dev/null
+++ b/labs/ICS-Simulation/infra/locals.tf
@@ -0,0 +1,385 @@
+locals {
+ project_name = "ICS-simulation"
+
+ networks = {
+ "ics-wan" = {
+ description = "WAN edge bridge with DHCP and NAT for the lab perimeter."
+ config = {
+ "ipv4.address" = "198.18.110.1/24"
+ "ipv4.dhcp" = "true"
+ "ipv4.nat" = "true"
+ "ipv6.address" = "none"
+ }
+ }
+ "ics-it" = {
+ description = "IT segment."
+ config = {
+ "ipv4.address" = "198.18.10.1/24"
+ "ipv4.dhcp" = "false"
+ "ipv4.nat" = "false"
+ "ipv6.address" = "none"
+ }
+ }
+ "ics-ot-dmz" = {
+ description = "OT DMZ segment."
+ config = {
+ "ipv4.address" = "198.18.20.1/24"
+ "ipv4.dhcp" = "false"
+ "ipv4.nat" = "false"
+ "ipv6.address" = "none"
+ }
+ }
+ "ics-ot-ops" = {
+ description = "OT operations segment."
+ config = {
+ "ipv4.address" = "198.18.30.1/24"
+ "ipv4.dhcp" = "false"
+ "ipv4.nat" = "false"
+ "ipv6.address" = "none"
+ }
+ }
+ "ics-ot-cell" = {
+ description = "OT cell/area segment."
+ config = {
+ "ipv4.address" = "198.18.40.1/24"
+ "ipv4.dhcp" = "false"
+ "ipv4.nat" = "false"
+ "ipv6.address" = "none"
+ }
+ }
+ }
+
+ instances = {
+ "fw01" = {
+ image = var.image_debian_cloud
+ cpu = 2
+ memory = "2GiB"
+ groups = ["router", "router_firewall", "wan"]
+ primary = "198.18.110.254"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt", "dnsmasq", "nftables"]
+ cloud_init_write_files = [
+ {
+ path = "/etc/nftables.conf"
+ permissions = "0644"
+ content = <<-EOT
+ #!/usr/sbin/nft -f
+
+ flush ruleset
+
+ define wan_if = "eth0"
+ define it_if = "eth1"
+ define dmz_if = "eth2"
+ define ops_if = "eth3"
+ define cell_if = "eth4"
+
+ table inet filter {
+ chain input {
+ type filter hook input priority filter; policy drop;
+
+ iif "lo" accept
+ ct state established,related accept
+ ct state invalid drop
+
+ ip protocol icmp accept
+ ip6 nexthdr ipv6-icmp accept
+
+ tcp dport 22 accept
+ udp dport 53 accept
+ tcp dport 53 accept
+ }
+
+ chain forward {
+ type filter hook forward priority filter; policy drop;
+
+ ct state established,related accept
+ ct state invalid drop
+
+ iifname { $it_if, $dmz_if, $ops_if, $cell_if } oifname $wan_if accept
+ iifname $it_if oifname $dmz_if tcp dport 22 accept
+ iifname $dmz_if oifname $ops_if tcp dport { 22, 443 } accept
+ iifname $ops_if oifname $cell_if tcp dport 502 accept
+ iifname $ops_if oifname $dmz_if tcp dport 1883 accept
+ iifname $wan_if oifname { $it_if, $dmz_if, $ops_if, $cell_if } drop
+ }
+
+ chain output {
+ type filter hook output priority filter; policy accept;
+ }
+ }
+
+ table ip nat {
+ chain postrouting {
+ type nat hook postrouting priority srcnat; policy accept;
+
+ oifname $wan_if ip saddr { 198.18.10.0/24, 198.18.20.0/24, 198.18.30.0/24, 198.18.40.0/24 } masquerade
+ }
+ }
+ EOT
+ },
+ {
+ path = "/etc/dnsmasq.d/ics-lab.conf"
+ permissions = "0644"
+ content = <<-EOT
+ domain-needed
+ bogus-priv
+ bind-interfaces
+ cache-size=1000
+ no-resolv
+
+ interface=eth0
+ interface=eth1
+ interface=eth2
+ interface=eth3
+ interface=eth4
+
+ listen-address=198.18.110.254
+ listen-address=198.18.10.254
+ listen-address=198.18.20.254
+ listen-address=198.18.30.254
+ listen-address=198.18.40.254
+
+ server=1.1.1.1
+ server=9.9.9.9
+ EOT
+ },
+ {
+ path = "/etc/sysctl.d/99-ics-router.conf"
+ permissions = "0644"
+ content = "net.ipv4.ip_forward=1\n"
+ }
+ ]
+ cloud_init_runcmd = [
+ ["sysctl", "--load=/etc/sysctl.d/99-ics-router.conf"],
+ ["systemctl", "enable", "--now", "ssh"],
+ ["systemctl", "enable", "--now", "nftables"],
+ ["systemctl", "enable", "--now", "dnsmasq"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-wan"
+ address = "198.18.110.254/24"
+ gateway = "198.18.110.1"
+ dns_servers = ["1.1.1.1", "9.9.9.9"]
+ },
+ {
+ name = "eth1"
+ network = "ics-it"
+ address = "198.18.10.254/24"
+ gateway = null
+ dns_servers = []
+ },
+ {
+ name = "eth2"
+ network = "ics-ot-dmz"
+ address = "198.18.20.254/24"
+ gateway = null
+ dns_servers = []
+ },
+ {
+ name = "eth3"
+ network = "ics-ot-ops"
+ address = "198.18.30.254/24"
+ gateway = null
+ dns_servers = []
+ },
+ {
+ name = "eth4"
+ network = "ics-ot-cell"
+ address = "198.18.40.254/24"
+ gateway = null
+ dns_servers = []
+ }
+ ]
+ }
+ "wan-test01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["wan_test", "wan"]
+ primary = "198.18.110.10"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-wan"
+ address = "198.18.110.10/24"
+ gateway = "198.18.110.254"
+ dns_servers = ["198.18.110.254"]
+ }
+ ]
+ }
+ "it-file01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["file_servers", "it"]
+ primary = "198.18.10.10"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-it"
+ address = "198.18.10.10/24"
+ gateway = "198.18.10.254"
+ dns_servers = ["198.18.10.254"]
+ }
+ ]
+ }
+ "it-ws01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["it_workstations", "it"]
+ primary = "198.18.10.20"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-it"
+ address = "198.18.10.20/24"
+ gateway = "198.18.10.254"
+ dns_servers = ["198.18.10.254"]
+ }
+ ]
+ }
+ "it-activity01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["activity_agents", "it"]
+ primary = "198.18.10.30"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-it"
+ address = "198.18.10.30/24"
+ gateway = "198.18.10.254"
+ dns_servers = ["198.18.10.254"]
+ }
+ ]
+ }
+ "otdmz-jump01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["jump_hosts", "ot_dmz"]
+ primary = "198.18.20.10"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-ot-dmz"
+ address = "198.18.20.10/24"
+ gateway = "198.18.20.254"
+ dns_servers = ["198.18.20.254"]
+ }
+ ]
+ }
+ "otops-scada01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["scada", "ot_ops"]
+ primary = "198.18.30.10"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-ot-ops"
+ address = "198.18.30.10/24"
+ gateway = "198.18.30.254"
+ dns_servers = ["198.18.30.254"]
+ }
+ ]
+ }
+ "otops-hmi01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["hmi", "ot_ops"]
+ primary = "198.18.30.20"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-ot-ops"
+ address = "198.18.30.20/24"
+ gateway = "198.18.30.254"
+ dns_servers = ["198.18.30.254"]
+ }
+ ]
+ }
+ "otcell-plc01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["plc", "ot_cell"]
+ primary = "198.18.40.10"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-ot-cell"
+ address = "198.18.40.10/24"
+ gateway = "198.18.40.254"
+ dns_servers = ["198.18.40.254"]
+ }
+ ]
+ }
+ "otcell-process01" = {
+ image = var.image_debian_cloud
+ cpu = 1
+ memory = "1GiB"
+ groups = ["process_simulators", "ot_cell"]
+ primary = "198.18.40.20"
+ cloud_init_packages = ["openssh-server", "sudo", "python3", "python3-apt"]
+ cloud_init_write_files = []
+ cloud_init_runcmd = [
+ ["systemctl", "enable", "--now", "ssh"]
+ ]
+ interfaces = [
+ {
+ name = "eth0"
+ network = "ics-ot-cell"
+ address = "198.18.40.20/24"
+ gateway = "198.18.40.254"
+ dns_servers = ["198.18.40.254"]
+ }
+ ]
+ }
+ }
+}
diff --git a/labs/ICS-Simulation/infra/main.tf b/labs/ICS-Simulation/infra/main.tf
new file mode 100644
index 0000000..24dd7fa
--- /dev/null
+++ b/labs/ICS-Simulation/infra/main.tf
@@ -0,0 +1,153 @@
+resource "incus_project" "ics_simulation" {
+ name = local.project_name
+ description = "Segmented ICS simulation lab project."
+
+ config = {
+ "features.images" = "false"
+ "features.profiles" = "true"
+ "features.storage.buckets" = "false"
+ "features.storage.volumes" = "false"
+ }
+}
+
+resource "incus_network" "ics_networks" {
+ for_each = local.networks
+
+ project = incus_project.ics_simulation.name
+ name = each.key
+ description = each.value.description
+ type = "bridge"
+ config = each.value.config
+}
+
+resource "incus_profile" "ics_base" {
+ project = incus_project.ics_simulation.name
+ name = "ics-base"
+ description = "Project-local base profile with root disk and conservative default sizing."
+
+ config = {
+ "boot.autostart" = "true"
+ "limits.cpu" = "1"
+ "limits.memory" = "1GiB"
+ }
+
+ device {
+ name = "root"
+ type = "disk"
+ properties = {
+ path = "/"
+ pool = var.storage_pool
+ }
+ }
+}
+
+resource "incus_instance" "fw01" {
+ project = incus_project.ics_simulation.name
+ name = "fw01"
+ image = local.instances["fw01"].image
+ type = "container"
+ running = true
+ profiles = [incus_profile.ics_base.name]
+
+ config = {
+ "boot.autostart" = "true"
+ "limits.cpu" = tostring(local.instances["fw01"].cpu)
+ "limits.memory" = local.instances["fw01"].memory
+ "linux.kernel_modules" = "nf_tables"
+ "user.access_interface" = "eth0"
+ "cloud-init.user-data" = templatefile("${path.module}/templates/cloud-init-user-data.tftpl", {
+ hostname = "fw01"
+ packages = local.instances["fw01"].cloud_init_packages
+ ssh_public_key = var.ssh_public_key
+ write_files = local.instances["fw01"].cloud_init_write_files
+ runcmd = local.instances["fw01"].cloud_init_runcmd
+ })
+ "cloud-init.network-config" = templatefile("${path.module}/templates/cloud-init-network-config.tftpl", {
+ interfaces = local.instances["fw01"].interfaces
+ })
+ }
+
+ dynamic "device" {
+ for_each = local.instances["fw01"].interfaces
+
+ content {
+ name = device.value.name
+ type = "nic"
+ properties = {
+ nictype = "bridged"
+ parent = incus_network.ics_networks[device.value.network].name
+ }
+ }
+ }
+
+ wait_for {
+ type = "ipv4"
+ nic = "eth0"
+ }
+}
+
+moved {
+ from = incus_instance.lab_nodes["fw01"]
+ to = incus_instance.fw01
+}
+
+resource "terraform_data" "fw01_cloud_init_ready" {
+ input = incus_instance.fw01.name
+
+ provisioner "local-exec" {
+ command = "${path.module}/../scripts/wait_for_cloud_init.sh ${incus_project.ics_simulation.name} ${incus_instance.fw01.name}"
+ }
+}
+
+resource "incus_instance" "lab_nodes" {
+ for_each = {
+ for hostname, node in local.instances : hostname => node
+ if hostname != "fw01"
+ }
+
+ project = incus_project.ics_simulation.name
+ name = each.key
+ image = each.value.image
+ type = "container"
+ running = true
+ profiles = [incus_profile.ics_base.name]
+
+ config = merge(
+ {
+ "boot.autostart" = "true"
+ "limits.cpu" = tostring(each.value.cpu)
+ "limits.memory" = each.value.memory
+ "user.access_interface" = "eth0"
+ "cloud-init.user-data" = templatefile("${path.module}/templates/cloud-init-user-data.tftpl", {
+ hostname = each.key
+ packages = each.value.cloud_init_packages
+ ssh_public_key = var.ssh_public_key
+ write_files = each.value.cloud_init_write_files
+ runcmd = each.value.cloud_init_runcmd
+ })
+ "cloud-init.network-config" = templatefile("${path.module}/templates/cloud-init-network-config.tftpl", {
+ interfaces = each.value.interfaces
+ })
+ }
+ )
+
+ depends_on = [terraform_data.fw01_cloud_init_ready]
+
+ dynamic "device" {
+ for_each = each.value.interfaces
+
+ content {
+ name = device.value.name
+ type = "nic"
+ properties = {
+ nictype = "bridged"
+ parent = incus_network.ics_networks[device.value.network].name
+ }
+ }
+ }
+
+ wait_for {
+ type = "ipv4"
+ nic = "eth0"
+ }
+}
diff --git a/labs/ICS-Simulation/infra/outputs.tf b/labs/ICS-Simulation/infra/outputs.tf
new file mode 100644
index 0000000..a4e6b03
--- /dev/null
+++ b/labs/ICS-Simulation/infra/outputs.tf
@@ -0,0 +1,14 @@
+output "project_name" {
+ description = "Incus project used for the lab."
+ value = incus_project.ics_simulation.name
+}
+
+output "hosts" {
+ description = "Hostnames mapped to static IPv4 addresses and intended Ansible groups."
+ value = {
+ for hostname, node in local.instances : hostname => {
+ ipv4 = node.primary
+ ansible_groups = node.groups
+ }
+ }
+}
diff --git a/labs/ICS-Simulation/infra/templates/cloud-init-network-config.tftpl b/labs/ICS-Simulation/infra/templates/cloud-init-network-config.tftpl
new file mode 100644
index 0000000..e727b9c
--- /dev/null
+++ b/labs/ICS-Simulation/infra/templates/cloud-init-network-config.tftpl
@@ -0,0 +1,19 @@
+version: 2
+ethernets:
+%{ for iface in interfaces ~}
+ ${iface.name}:
+ dhcp4: false
+ dhcp6: false
+ addresses:
+ - ${iface.address}
+%{ if iface.gateway != null ~}
+ gateway4: ${iface.gateway}
+%{ endif ~}
+%{ if length(iface.dns_servers) > 0 ~}
+ nameservers:
+ addresses:
+%{ for dns_server in iface.dns_servers ~}
+ - ${dns_server}
+%{ endfor ~}
+%{ endif ~}
+%{ endfor ~}
diff --git a/labs/ICS-Simulation/infra/templates/cloud-init-user-data.tftpl b/labs/ICS-Simulation/infra/templates/cloud-init-user-data.tftpl
new file mode 100644
index 0000000..3501957
--- /dev/null
+++ b/labs/ICS-Simulation/infra/templates/cloud-init-user-data.tftpl
@@ -0,0 +1,35 @@
+#cloud-config
+hostname: ${hostname}
+fqdn: ${hostname}
+manage_etc_hosts: true
+package_update: true
+package_upgrade: false
+packages:
+%{ for pkg in packages ~}
+ - ${pkg}
+%{ endfor ~}
+users:
+ - default
+ - name: lab
+ shell: /bin/bash
+ groups:
+ - sudo
+ sudo: ALL=(ALL) NOPASSWD:ALL
+ ssh_authorized_keys:
+ - ${ssh_public_key}
+ssh_pwauth: false
+%{ if length(write_files) > 0 ~}
+write_files:
+%{ for item in write_files ~}
+ - path: ${item.path}
+ permissions: "${item.permissions}"
+ content: |
+%{ for line in split("\n", chomp(item.content)) ~}
+ ${line}
+%{ endfor ~}
+%{ endfor ~}
+%{ endif ~}
+runcmd:
+%{ for command in runcmd ~}
+ - [${join(", ", formatlist("\"%s\"", command))}]
+%{ endfor ~}
diff --git a/labs/ICS-Simulation/infra/terraform.tfvars b/labs/ICS-Simulation/infra/terraform.tfvars
new file mode 100644
index 0000000..630d934
--- /dev/null
+++ b/labs/ICS-Simulation/infra/terraform.tfvars
@@ -0,0 +1,4 @@
+storage_pool = "incus-storage"
+ssh_public_key = "ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILRvJIjw3P/0Vwv/4Z2Ye5LjqP6JhnZ2T/pTMLrnYGso user@ubuntour"
+image_debian_cloud = "images:debian/12/cloud"
+image_ubuntu_cloud = "images:ubuntu/24.04/cloud"
diff --git a/labs/ICS-Simulation/infra/variables.tf b/labs/ICS-Simulation/infra/variables.tf
new file mode 100644
index 0000000..831a41e
--- /dev/null
+++ b/labs/ICS-Simulation/infra/variables.tf
@@ -0,0 +1,22 @@
+variable "storage_pool" {
+ description = "Existing Incus storage pool to use for the ics-base profile root disk."
+ type = string
+}
+
+variable "image_debian_cloud" {
+ description = "Incus image reference for Debian cloud containers."
+ type = string
+ default = "images:debian/12/cloud"
+}
+
+variable "image_ubuntu_cloud" {
+ description = "Incus image reference for Ubuntu cloud containers. Declared for optional extensions and OCI wrapper patterns."
+ type = string
+ default = "images:ubuntu/24.04/cloud"
+}
+
+variable "ssh_public_key" {
+ description = "SSH public key injected into /home/lab/.ssh/authorized_keys by cloud-init."
+ type = string
+ sensitive = true
+}
diff --git a/labs/ICS-Simulation/infra/versions.tf b/labs/ICS-Simulation/infra/versions.tf
new file mode 100644
index 0000000..3a715c9
--- /dev/null
+++ b/labs/ICS-Simulation/infra/versions.tf
@@ -0,0 +1,12 @@
+terraform {
+ required_version = ">= 1.6.0"
+
+ required_providers {
+ incus = {
+ source = "lxc/incus"
+ version = ">= 1.0.0"
+ }
+ }
+}
+
+provider "incus" {}
diff --git a/labs/ICS-Simulation/scripts/__pycache__/generate_inventory.cpython-312.pyc b/labs/ICS-Simulation/scripts/__pycache__/generate_inventory.cpython-312.pyc
new file mode 100644
index 0000000..90edc65
Binary files /dev/null and b/labs/ICS-Simulation/scripts/__pycache__/generate_inventory.cpython-312.pyc differ
diff --git a/labs/ICS-Simulation/scripts/generate_inventory.py b/labs/ICS-Simulation/scripts/generate_inventory.py
new file mode 100755
index 0000000..df31bdc
--- /dev/null
+++ b/labs/ICS-Simulation/scripts/generate_inventory.py
@@ -0,0 +1,44 @@
+#!/usr/bin/env python3
+
+import json
+from pathlib import Path
+
+
+REPO_ROOT = Path(__file__).resolve().parent.parent
+INPUT_PATH = REPO_ROOT / "infra_outputs.json"
+OUTPUT_PATH = REPO_ROOT / "ansible" / "inventory" / "inventory.yml"
+
+
+def main() -> None:
+ payload = json.loads(INPUT_PATH.read_text())
+ hosts = payload["hosts"]["value"]
+
+ groups = {}
+ all_hosts = {}
+
+ for hostname, metadata in sorted(hosts.items()):
+ ipv4 = metadata["ipv4"]
+ all_hosts[hostname] = {"ansible_host": ipv4}
+ for group in metadata["ansible_groups"]:
+ groups.setdefault(group, {})
+ groups[group][hostname] = {"ansible_host": ipv4}
+
+ lines = ["all:", " hosts:"]
+ for hostname, hostvars in all_hosts.items():
+ lines.append(f" {hostname}:")
+ lines.append(f" ansible_host: {hostvars['ansible_host']}")
+
+ lines.append(" children:")
+ for group_name in sorted(groups):
+ lines.append(f" {group_name}:")
+ lines.append(" hosts:")
+ for hostname, hostvars in sorted(groups[group_name].items()):
+ lines.append(f" {hostname}:")
+ lines.append(f" ansible_host: {hostvars['ansible_host']}")
+
+ OUTPUT_PATH.write_text("\n".join(lines) + "\n")
+ print(f"Wrote inventory to {OUTPUT_PATH}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/labs/ICS-Simulation/scripts/oci-wrapper.sh b/labs/ICS-Simulation/scripts/oci-wrapper.sh
new file mode 100755
index 0000000..1a5fea6
--- /dev/null
+++ b/labs/ICS-Simulation/scripts/oci-wrapper.sh
@@ -0,0 +1,14 @@
+#!/usr/bin/env sh
+set -eu
+
+if command -v ip >/dev/null 2>&1; then
+ if [ -n "${OCI_STATIC_IP:-}" ]; then
+ ip addr add "${OCI_STATIC_IP}" dev eth0 || true
+ fi
+
+ if [ -n "${OCI_GATEWAY:-}" ]; then
+ ip route replace default via "${OCI_GATEWAY}" || true
+ fi
+fi
+
+exec "$@"
diff --git a/labs/ICS-Simulation/scripts/oci_deploy.sh b/labs/ICS-Simulation/scripts/oci_deploy.sh
new file mode 100755
index 0000000..ecc86ae
--- /dev/null
+++ b/labs/ICS-Simulation/scripts/oci_deploy.sh
@@ -0,0 +1,43 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT="ICS-simulation"
+REMOTE_NAME="oci-docker"
+
+ensure_remote() {
+ if ! incus remote list --format csv | awk -F, '{print $1}' | grep -qx "${REMOTE_NAME}"; then
+ incus remote add "${REMOTE_NAME}" https://docker.io --protocol=oci
+ fi
+}
+
+launch_if_missing() {
+ local image="$1"
+ local name="$2"
+ local network="$3"
+
+ if incus info --project "${PROJECT}" "${name}" >/dev/null 2>&1; then
+ echo "Skipping ${name}: already present"
+ return
+ fi
+
+ incus launch --project "${PROJECT}" --network "${network}" "${image}" "${name}"
+}
+
+ensure_remote
+
+launch_if_missing "${REMOTE_NAME}:eclipse-mosquitto" mqtt01 ics-ot-dmz
+
+if [[ "${DEPLOY_OBSERVABILITY:-0}" == "1" ]]; then
+ launch_if_missing "${REMOTE_NAME}:influxdb:2.7" influxdb01 ics-ot-ops
+ launch_if_missing "${REMOTE_NAME}:grafana/grafana" grafana01 ics-ot-ops
+fi
+
+cat <<'EOF'
+OCI deployment attempted.
+
+Note:
+- `mqtt01` is attached to `ics-ot-dmz`.
+- Optional `influxdb01` and `grafana01` are attached to `ics-ot-ops` when `DEPLOY_OBSERVABILITY=1`.
+- OCI app containers can require manual network bootstrap when attached to bridges without DHCP.
+- See `scripts/oci-wrapper.sh` and the top-level README for an `oci.entrypoint` wrapper pattern.
+EOF
diff --git a/labs/ICS-Simulation/scripts/smoke_test.sh b/labs/ICS-Simulation/scripts/smoke_test.sh
new file mode 100755
index 0000000..d59ead4
--- /dev/null
+++ b/labs/ICS-Simulation/scripts/smoke_test.sh
@@ -0,0 +1,68 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT="${PROJECT:-ICS-simulation}"
+SCADA_API_URL="${SCADA_API_URL:-http://198.18.30.10:8081/api/tags}"
+HMI_URL="${HMI_URL:-http://198.18.30.20:8080/}"
+
+run_in_instance() {
+ local instance="$1"
+ local command="$2"
+ incus exec --project "${PROJECT}" "${instance}" -- bash -lc "${command}"
+}
+
+pass() {
+ printf '[PASS] %s\n' "$1"
+}
+
+fail() {
+ printf '[FAIL] %s\n' "$1" >&2
+ exit 1
+}
+
+require_success() {
+ local message="$1"
+ local instance="$2"
+ local command="$3"
+
+ if run_in_instance "${instance}" "${command}" >/dev/null 2>&1; then
+ pass "${message}"
+ else
+ fail "${message}"
+ fi
+}
+
+require_failure() {
+ local message="$1"
+ local instance="$2"
+ local command="$3"
+
+ if run_in_instance "${instance}" "${command}" >/dev/null 2>&1; then
+ fail "${message}"
+ else
+ pass "${message}"
+ fi
+}
+
+require_success "IT to DMZ SSH allowed" it-ws01 "nc -zvw3 198.18.20.10 22"
+require_failure "IT to CELL blocked" it-ws01 "nc -zvw3 198.18.40.10 502"
+require_success "OPS to CELL Modbus/TCP allowed" otops-scada01 "nc -zvw3 198.18.40.10 502"
+
+MQTT_IP="$(incus list --project "${PROJECT}" mqtt01 -c 4 --format csv 2>/dev/null | head -n1 | awk '{print $1}' | cut -d',' -f1 || true)"
+if [[ -n "${MQTT_IP}" ]]; then
+ require_success "SCADA can reach MQTT broker" otops-scada01 "nc -zvw3 ${MQTT_IP} 1883"
+else
+ printf '[SKIP] MQTT smoke test skipped because mqtt01 is not deployed or has no detected IPv4 address\n'
+fi
+
+if curl -fsS "${SCADA_API_URL}" | grep -q '"tags"'; then
+ pass "SCADA API returns tags"
+else
+ fail "SCADA API returns tags"
+fi
+
+if curl -fsS "${HMI_URL}" | grep -qi 'ICS HMI'; then
+ pass "HMI page loads"
+else
+ fail "HMI page loads"
+fi
diff --git a/labs/ICS-Simulation/scripts/wait_for_cloud_init.sh b/labs/ICS-Simulation/scripts/wait_for_cloud_init.sh
new file mode 100755
index 0000000..9c824a7
--- /dev/null
+++ b/labs/ICS-Simulation/scripts/wait_for_cloud_init.sh
@@ -0,0 +1,20 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+PROJECT="${1:?usage: wait_for_cloud_init.sh }"
+INSTANCE="${2:?usage: wait_for_cloud_init.sh }"
+TIMEOUT_SECONDS="${TIMEOUT_SECONDS:-600}"
+SLEEP_SECONDS="${SLEEP_SECONDS:-5}"
+
+deadline=$((SECONDS + TIMEOUT_SECONDS))
+
+while (( SECONDS < deadline )); do
+ if incus exec --project "${PROJECT}" "${INSTANCE}" -- cloud-init status --wait >/dev/null 2>&1; then
+ exit 0
+ fi
+
+ sleep "${SLEEP_SECONDS}"
+done
+
+echo "Timed out waiting for cloud-init on ${INSTANCE} in project ${PROJECT}" >&2
+exit 1