diff --git a/CLAUDE.md b/CLAUDE.md index 2bdb921..ea948b7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -9,6 +9,8 @@ This project contains design documentation for a distributed SCADA system built - `Component-*.md` — Individual component design documents (one per component). - `docs/plans/` — Design decision documents from refinement sessions. - `AkkaDotNet/` — Akka.NET reference documentation and best practices notes. +- `test_infra.md` — Master test infrastructure doc (OPC UA, LDAP, MS SQL). +- `infra/` — Docker Compose and config files for local test services. There is no source code in this project — only design documentation in markdown. diff --git a/docs/plans/questions.md b/docs/plans/questions.md index 35856d1..828eff7 100644 --- a/docs/plans/questions.md +++ b/docs/plans/questions.md @@ -6,30 +6,11 @@ ## Open Questions -### Phase 0: Solution Skeleton - -| # | Question | Context | Impact | Status | -|---|----------|---------|--------|--------| -| Q1 | What .NET version should we target? (.NET 8 LTS or .NET 9?) | Affects available APIs, Akka.NET compatibility, and support lifecycle. | Phase 0 — must decide before creating solution. | Open | -| Q2 | What Akka.NET version? (v1.5.x stable or preview features?) | Affects hosting APIs, serialization options, and cluster features. | Phase 0 — must decide before creating solution. | Open | -| Q3 | Should we use a monorepo (single .sln) or separate repos per component? | Affects CI/CD, versioning, and developer workflow. Monorepo recommended for a tightly coupled system. | Phase 0. | Open | -| Q4 | What CI/CD platform? (GitHub Actions, Azure DevOps, Jenkins, etc.) | Affects pipeline configuration in Phase 0. | Phase 0. | Open | - -### Phase 1: Central Foundations - -| # | Question | Context | Impact | Status | -|---|----------|---------|--------|--------| -| Q5 | What LDAP server will be used for dev/test? (Real AD, lightweight LDAP like OpenLDAP, or mock?) | Affects integration test setup for Security & Auth. | Phase 1 — needed for auth development. | Open | -| Q6 | What MS SQL version and hosting? (SQL Server 2019/2022, Azure SQL, local?) | Affects EF Core provider and connection string configuration. | Phase 1 — needed for Config DB. | Open | -| Q7 | Should the JWT signing key be stored in appsettings.json, Azure Key Vault, or elsewhere? | Affects security posture and operational procedures. Currently specified as "configuration." | Phase 1. | Open | - ### Phase 3: Site Execution | # | Question | Context | Impact | Status | |---|----------|---------|--------|--------| -| Q8 | Is a real OPC UA server available for dev/test, or do we need a simulator? | Affects Data Connection Layer development. Best practices doc recommends mock protocol adapters. | Phase 3 — needed for DCL development. | Open | -| Q9 | What is the custom protocol? Is there an existing specification or SDK? | The design mentions "custom protocol" as a second adapter alongside OPC UA. Need details to implement. | Phase 3. | Open | -| Q10 | What is the target site hardware? (Server specs, network topology between nodes) | Affects performance tuning and Akka.NET dispatcher configuration. | Phase 3, Phase 8. | Open | +| Q9 | What is the custom protocol? Is there an existing specification or SDK? | The design mentions "custom protocol" as a second adapter alongside OPC UA. Need details to implement. | Phase 3. | Deferred — owner will provide details later | ### Phase 7: Integrations @@ -52,4 +33,12 @@ | # | Question | Resolution | Date | |---|----------|------------|------| -| — | (none yet) | — | — | +| Q1 | What .NET version should we target? | .NET 10 LTS (released November 2025, supported through 2028). | 2026-03-16 | +| Q2 | What Akka.NET version? | Latest stable 1.5.x (currently 1.5.62). | 2026-03-16 | +| Q3 | Monorepo or separate repos? | Single monorepo with SLNX solution file (`.slnx`, the new XML-based format default in .NET 10). | 2026-03-16 | +| Q4 | What CI/CD platform? | None for now. No CI/CD pipeline. | 2026-03-16 | +| Q5 | What LDAP server for dev/test? | GLAuth (lightweight LDAP) in Docker. See `infra/glauth/config.toml` and `test_infra_ldap.md`. | 2026-03-16 | +| Q6 | What MS SQL version and hosting? | SQL Server 2022 Developer Edition in Docker. See `infra/docker-compose.yml` and `test_infra_db.md`. | 2026-03-16 | +| Q7 | JWT signing key storage? | `appsettings.json` (per environment). | 2026-03-16 | +| Q8 | OPC UA server for dev/test? | Azure IoT OPC PLC simulator in Docker. See `infra/opcua/nodes.json` and `test_infra_opcua.md`. | 2026-03-16 | +| Q10 | Target site hardware? | Windows Server 2022, 24 GB RAM, 1 TB drive, 16-core Xeon. | 2026-03-16 | diff --git a/infra/.gitignore b/infra/.gitignore new file mode 100644 index 0000000..761a099 --- /dev/null +++ b/infra/.gitignore @@ -0,0 +1 @@ +tools/.venv/ diff --git a/infra/README.md b/infra/README.md new file mode 100644 index 0000000..dd2a0e0 --- /dev/null +++ b/infra/README.md @@ -0,0 +1,95 @@ +# ScadaLink Test Infrastructure + +Local Docker-based test services for ScadaLink development. + +## Quick Start + +```bash +docker compose up -d +``` + +This starts three services: + +| Service | Port | Purpose | +|---------|------|---------| +| OPC UA (Azure IoT OPC PLC) | 50000 (OPC UA), 8080 (web) | Simulated OPC UA server with ScadaLink-style tags | +| LDAP (GLAuth) | 3893 | Lightweight LDAP with test users/groups matching ScadaLink roles | +| MS SQL 2022 | 1433 | Configuration and machine data databases | + +## First-Time SQL Setup + +The MS SQL container does not auto-run init scripts. After the first `docker compose up -d`, run: + +```bash +docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1!' -C \ + -i /docker-entrypoint-initdb.d/setup.sql +``` + +This creates the `ScadaLinkConfig` and `ScadaLinkMachineData` databases and the `scadalink_app` login. + +## Stopping & Teardown + +**Stop containers** (data persists in SQL volume): +```bash +docker compose down +``` + +**Stop a single service** (leave the others running): +```bash +docker compose stop opcua # or: ldap, mssql +docker compose start opcua # bring it back without recreating +``` + +**Full teardown** (stop containers, delete SQL data volume, remove pulled images): +```bash +./teardown.sh +``` + +Or manually: +```bash +docker compose down -v # stop containers + delete SQL data volume +docker compose down -v --rmi all # also remove downloaded images +``` + +After a full teardown, the next `docker compose up -d` starts fresh — you'll need to re-run the SQL setup script. + +## CLI Tools + +Python CLI tools for interacting with the test services are in `tools/`. + +**Set up a Python virtual environment** (one-time): +```bash +python3 -m venv tools/.venv && source tools/.venv/bin/activate +``` + +**Install dependencies** (one-time, with venv active): +```bash +pip install -r tools/requirements.txt +``` + +> The `.venv` directory is gitignored. + +**Quick readiness check** (all three services, with venv active): +```bash +python tools/opcua_tool.py check +python tools/ldap_tool.py check +python tools/mssql_tool.py check +``` + +| Tool | Service | Key Commands | +|------|---------|-------------| +| `tools/opcua_tool.py` | OPC UA | `check`, `browse`, `read`, `write`, `monitor` | +| `tools/ldap_tool.py` | LDAP | `check`, `bind`, `search`, `users`, `groups` | +| `tools/mssql_tool.py` | MS SQL | `check`, `setup`, `query`, `tables` | + +Each tool supports `--help` for full usage. See the per-service docs below for detailed examples. + +## Detailed Documentation + +See the project root for per-service setup guides: + +- [test_infra.md](../test_infra.md) — Master test infrastructure overview +- [test_infra_opcua.md](../test_infra_opcua.md) — OPC UA server details +- [test_infra_ldap.md](../test_infra_ldap.md) — LDAP server details +- [test_infra_db.md](../test_infra_db.md) — MS SQL database details diff --git a/infra/docker-compose.yml b/infra/docker-compose.yml new file mode 100644 index 0000000..bd99541 --- /dev/null +++ b/infra/docker-compose.yml @@ -0,0 +1,45 @@ +services: + opcua: + image: mcr.microsoft.com/iotedge/opc-plc:latest + container_name: scadalink-opcua + ports: + - "50000:50000" + - "8080:8080" + volumes: + - ./opcua/nodes.json:/app/config/nodes.json:ro + command: > + --autoaccept + --unsecuretransport + --sph + --sn=5 --sr=10 --st=uint + --fn=5 --fr=1 --ft=uint + --gn=5 + --nf=/app/config/nodes.json + --pn=50000 + restart: unless-stopped + + ldap: + image: glauth/glauth:latest + container_name: scadalink-ldap + ports: + - "3893:3893" + volumes: + - ./glauth/config.toml:/app/config/config.cfg:ro + restart: unless-stopped + + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + container_name: scadalink-mssql + ports: + - "1433:1433" + environment: + ACCEPT_EULA: "Y" + MSSQL_SA_PASSWORD: "ScadaLink_Dev1!" + MSSQL_PID: "Developer" + volumes: + - scadalink-mssql-data:/var/opt/mssql + - ./mssql/setup.sql:/docker-entrypoint-initdb.d/setup.sql:ro + restart: unless-stopped + +volumes: + scadalink-mssql-data: diff --git a/infra/glauth/config.toml b/infra/glauth/config.toml new file mode 100644 index 0000000..cabe541 --- /dev/null +++ b/infra/glauth/config.toml @@ -0,0 +1,81 @@ +[ldap] + enabled = true + listen = "0.0.0.0:3893" + +[ldaps] + enabled = false + +[backend] + datastore = "config" + baseDN = "dc=scadalink,dc=local" + +# ── Groups ────────────────────────────────────────────────────────── + +[[groups]] + name = "SCADA-Admins" + gidnumber = 5501 + +[[groups]] + name = "SCADA-Designers" + gidnumber = 5502 + +[[groups]] + name = "SCADA-Deploy-All" + gidnumber = 5503 + +[[groups]] + name = "SCADA-Deploy-SiteA" + gidnumber = 5504 + +# ── Users ─────────────────────────────────────────────────────────── +# All test passwords: "password" +# SHA256 of "password": 5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8 + +[[users]] + name = "admin" + givenname = "Admin" + sn = "User" + mail = "admin@scadalink.local" + uidnumber = 5001 + primarygroup = 5501 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + [[users.capabilities]] + action = "search" + object = "*" + +[[users]] + name = "designer" + givenname = "Designer" + sn = "User" + mail = "designer@scadalink.local" + uidnumber = 5002 + primarygroup = 5502 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +[[users]] + name = "deployer" + givenname = "Deployer" + sn = "User" + mail = "deployer@scadalink.local" + uidnumber = 5003 + primarygroup = 5503 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +[[users]] + name = "site-deployer" + givenname = "Site" + sn = "Deployer" + mail = "site-deployer@scadalink.local" + uidnumber = 5004 + primarygroup = 5504 + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" + +[[users]] + name = "multi-role" + givenname = "Multi" + sn = "Role" + mail = "multi-role@scadalink.local" + uidnumber = 5005 + primarygroup = 5501 + othergroups = [5502, 5503] + passsha256 = "5e884898da28047151d0e56f8dc6292773603d0d6aabbdd62a11ef721d1542d8" diff --git a/infra/mssql/setup.sql b/infra/mssql/setup.sql new file mode 100644 index 0000000..aa43c1b --- /dev/null +++ b/infra/mssql/setup.sql @@ -0,0 +1,36 @@ +-- ScadaLink development database setup +-- Run against a fresh MS SQL 2022 instance. +-- EF Core migrations handle schema creation; this script only creates +-- the empty databases and the application login/user. + +-- Create databases +IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkConfig') + CREATE DATABASE ScadaLinkConfig; +GO + +IF NOT EXISTS (SELECT name FROM sys.databases WHERE name = 'ScadaLinkMachineData') + CREATE DATABASE ScadaLinkMachineData; +GO + +-- Create application login +IF NOT EXISTS (SELECT name FROM sys.server_principals WHERE name = 'scadalink_app') + CREATE LOGIN scadalink_app WITH PASSWORD = 'ScadaLink_Dev1!', DEFAULT_DATABASE = ScadaLinkConfig; +GO + +-- Grant db_owner on ScadaLinkConfig +USE ScadaLinkConfig; +GO +IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'scadalink_app') + CREATE USER scadalink_app FOR LOGIN scadalink_app; +GO +ALTER ROLE db_owner ADD MEMBER scadalink_app; +GO + +-- Grant db_owner on ScadaLinkMachineData +USE ScadaLinkMachineData; +GO +IF NOT EXISTS (SELECT name FROM sys.database_principals WHERE name = 'scadalink_app') + CREATE USER scadalink_app FOR LOGIN scadalink_app; +GO +ALTER ROLE db_owner ADD MEMBER scadalink_app; +GO diff --git a/infra/opcua/nodes.json b/infra/opcua/nodes.json new file mode 100644 index 0000000..fca0733 --- /dev/null +++ b/infra/opcua/nodes.json @@ -0,0 +1,138 @@ +{ + "Folder": "ScadaLink", + "NodeList": [], + "FolderList": [ + { + "Folder": "Motor", + "NodeList": [ + { + "NodeId": "Motor.Speed", + "Name": "Speed", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Motor speed in RPM" + }, + { + "NodeId": "Motor.Temperature", + "Name": "Temperature", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Motor bearing temperature in Celsius" + }, + { + "NodeId": "Motor.Current", + "Name": "Current", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Motor current draw in Amps" + }, + { + "NodeId": "Motor.Running", + "Name": "Running", + "DataType": "Boolean", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Motor running status" + }, + { + "NodeId": "Motor.FaultCode", + "Name": "FaultCode", + "DataType": "UInt32", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Active fault code (0 = no fault)" + } + ] + }, + { + "Folder": "Pump", + "NodeList": [ + { + "NodeId": "Pump.FlowRate", + "Name": "FlowRate", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Flow rate in liters per minute" + }, + { + "NodeId": "Pump.Pressure", + "Name": "Pressure", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Discharge pressure in bar" + }, + { + "NodeId": "Pump.Running", + "Name": "Running", + "DataType": "Boolean", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Pump running status" + } + ] + }, + { + "Folder": "Tank", + "NodeList": [ + { + "NodeId": "Tank.Level", + "Name": "Level", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Tank level in percent" + }, + { + "NodeId": "Tank.Temperature", + "Name": "Temperature", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Tank contents temperature in Celsius" + }, + { + "NodeId": "Tank.HighLevel", + "Name": "HighLevel", + "DataType": "Boolean", + "ValueRank": -1, + "AccessLevel": "CurrentRead", + "Description": "High level alarm switch" + }, + { + "NodeId": "Tank.LowLevel", + "Name": "LowLevel", + "DataType": "Boolean", + "ValueRank": -1, + "AccessLevel": "CurrentRead", + "Description": "Low level alarm switch" + } + ] + }, + { + "Folder": "Valve", + "NodeList": [ + { + "NodeId": "Valve.Position", + "Name": "Position", + "DataType": "Double", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Valve position in percent open" + }, + { + "NodeId": "Valve.Command", + "Name": "Command", + "DataType": "UInt32", + "ValueRank": -1, + "AccessLevel": "CurrentReadOrWrite", + "Description": "Valve command (0=Close, 1=Open, 2=Stop)" + } + ] + } + ] +} diff --git a/infra/teardown.sh b/infra/teardown.sh new file mode 100755 index 0000000..7ba258e --- /dev/null +++ b/infra/teardown.sh @@ -0,0 +1,47 @@ +#!/usr/bin/env bash +# Tear down ScadaLink test infrastructure. +# +# Usage: +# ./teardown.sh Stop containers and delete the SQL data volume +# ./teardown.sh --images Also remove downloaded Docker images +# ./teardown.sh --all Remove volumes, images, and the Python venv + +set -euo pipefail +cd "$(dirname "$0")" + +REMOVE_IMAGES=false +REMOVE_VENV=false + +for arg in "$@"; do + case "$arg" in + --images) REMOVE_IMAGES=true ;; + --all) REMOVE_IMAGES=true; REMOVE_VENV=true ;; + -h|--help) + echo "Usage: ./teardown.sh [--images] [--all]" + echo " (no flags) Stop containers, delete SQL data volume" + echo " --images Also remove downloaded Docker images" + echo " --all Remove volumes, images, and Python venv" + exit 0 + ;; + *) + echo "Unknown option: $arg" >&2 + exit 1 + ;; + esac +done + +echo "Stopping containers and removing SQL data volume..." +if $REMOVE_IMAGES; then + docker compose down -v --rmi all +else + docker compose down -v +fi + +if $REMOVE_VENV && [ -d tools/.venv ]; then + echo "Removing Python virtual environment..." + rm -rf tools/.venv +fi + +echo "" +echo "Teardown complete." +echo "To start fresh: docker compose up -d && python tools/mssql_tool.py setup --script mssql/setup.sql" diff --git a/infra/tools/ldap_tool.py b/infra/tools/ldap_tool.py new file mode 100644 index 0000000..7ed37cf --- /dev/null +++ b/infra/tools/ldap_tool.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""LDAP client tool for ScadaLink test infrastructure.""" + +import argparse +import sys + +from ldap3 import Server, Connection, NONE, SUBTREE, SIMPLE + + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 3893 +DEFAULT_BASE_DN = "dc=scadalink,dc=local" +# GLAuth places users under ou=,ou=users,dc=... +# The admin user (primarygroup SCADA-Admins) needs search capabilities in config. +DEFAULT_BIND_DN = "cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local" +DEFAULT_BIND_PASSWORD = "password" + + +def make_connection(args): + """Create and return an authenticated LDAP connection.""" + server = Server(args.host, port=args.port, get_info=NONE) + bind_dn = getattr(args, "bind_dn", DEFAULT_BIND_DN) or DEFAULT_BIND_DN + bind_password = getattr(args, "bind_password", DEFAULT_BIND_PASSWORD) or DEFAULT_BIND_PASSWORD + conn = Connection(server, user=bind_dn, password=bind_password, authentication=SIMPLE, auto_bind=True) + return conn + + +def cmd_check(args): + """Test LDAP connectivity and base DN search.""" + try: + conn = make_connection(args) + print(f"Connected to: {args.host}:{args.port}") + print(f"Bind DN: {DEFAULT_BIND_DN}") + + conn.search(DEFAULT_BASE_DN, "(objectClass=*)", search_scope=SUBTREE, attributes=["dn"]) + print(f"Base DN search ({DEFAULT_BASE_DN}): {len(conn.entries)} entries found") + + for entry in conn.entries: + print(f" {entry.entry_dn}") + + conn.unbind() + print("\nLDAP server is healthy.") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_bind(args): + """Test user authentication via bind. + + GLAuth DN format: cn=,ou=,ou=users,dc=scadalink,dc=local + Since we don't know the user's primary group upfront, we search for the user first + to discover the full DN, then rebind with that DN. + """ + try: + # First, use admin to find the user's actual DN + admin_conn = make_connection(args) + admin_conn.search( + DEFAULT_BASE_DN, + f"(cn={args.user})", + search_scope=SUBTREE, + attributes=["cn"], + ) + + if not admin_conn.entries: + print(f"User not found: {args.user}", file=sys.stderr) + sys.exit(1) + + # Find the user entry (skip group entries that also match cn) + user_dn = None + for entry in admin_conn.entries: + dn = entry.entry_dn + if dn.startswith(f"cn={args.user},"): + user_dn = dn + break + + admin_conn.unbind() + + if not user_dn: + print(f"Could not resolve DN for user: {args.user}", file=sys.stderr) + sys.exit(1) + + # Now bind with the discovered DN + server = Server(args.host, port=args.port, get_info=NONE) + conn = Connection(server, user=user_dn, password=args.password, authentication=SIMPLE, auto_bind=True) + print(f"Bind successful: {user_dn}") + conn.unbind() + except Exception as e: + print(f"Bind failed for {args.user}: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_search(args): + """Search with arbitrary LDAP filter.""" + try: + conn = make_connection(args) + base = args.base or DEFAULT_BASE_DN + conn.search(base, args.filter, search_scope=SUBTREE, attributes=["*"]) + + print(f"Filter: {args.filter}") + print(f"Base: {base}") + print(f"Results: {len(conn.entries)}\n") + + for entry in conn.entries: + print(f"DN: {entry.entry_dn}") + for attr in entry.entry_attributes: + print(f" {attr}: {entry[attr].value}") + print() + + conn.unbind() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_users(args): + """List all users with group memberships.""" + try: + conn = make_connection(args) + + # Find all groups to map GIDs and memberships + conn.search(DEFAULT_BASE_DN, "(objectClass=posixGroup)", + search_scope=SUBTREE, attributes=["cn", "gidNumber", "memberUid"]) + groups_by_gid = {} + groups_with_members = {} + for entry in conn.entries: + gid = entry["gidNumber"].value if "gidNumber" in entry.entry_attributes else None + cn = entry["cn"].value + if gid: + groups_by_gid[int(gid)] = cn + member_uids = entry["memberUid"].value if "memberUid" in entry.entry_attributes else [] + if isinstance(member_uids, str): + member_uids = [member_uids] + groups_with_members[cn] = member_uids + + # Find all users + conn.search(DEFAULT_BASE_DN, "(objectClass=posixAccount)", + search_scope=SUBTREE, attributes=["cn", "mail", "gidNumber"]) + + print("Users:") + print(f"{'Username':<20} {'Email':<35} {'Primary Group':<25} {'Additional Groups'}") + print("-" * 110) + + for entry in conn.entries: + cn = entry["cn"].value + mail = entry["mail"].value if "mail" in entry.entry_attributes else "" + gid = int(entry["gidNumber"].value) if "gidNumber" in entry.entry_attributes else None + primary = groups_by_gid.get(gid, str(gid)) if gid else "" + + # Find additional groups via memberUid + additional = [] + for group_name, members in groups_with_members.items(): + if cn in members and group_name != primary: + additional.append(group_name) + + print(f"{cn:<20} {mail:<35} {primary:<25} {', '.join(additional)}") + + conn.unbind() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_groups(args): + """List all groups with member counts.""" + try: + conn = make_connection(args) + conn.search(DEFAULT_BASE_DN, "(objectClass=posixGroup)", + search_scope=SUBTREE, attributes=["cn", "gidNumber", "memberUid", "description"]) + + print("Groups:") + print(f"{'Group':<25} {'GID':<10} {'Members'}") + print("-" * 70) + + for entry in conn.entries: + cn = entry["cn"].value + gid = entry["gidNumber"].value if "gidNumber" in entry.entry_attributes else "" + members = entry["memberUid"].value if "memberUid" in entry.entry_attributes else [] + if isinstance(members, str): + members = [members] + member_list = ", ".join(members) if members else "(none)" + print(f"{cn:<25} {gid:<10} {member_list}") + + conn.unbind() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="LDAP client tool for ScadaLink test infrastructure") + parser.add_argument("--host", default=DEFAULT_HOST, help=f"LDAP host (default: {DEFAULT_HOST})") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"LDAP port (default: {DEFAULT_PORT})") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("check", help="Test LDAP connectivity") + + bind_p = sub.add_parser("bind", help="Test user authentication") + bind_p.add_argument("--user", required=True, help="Username (e.g. designer)") + bind_p.add_argument("--password", default="password", help="Password (default: password)") + + search_p = sub.add_parser("search", help="Search with LDAP filter") + search_p.add_argument("--bind-dn", default=DEFAULT_BIND_DN, help="Bind DN") + search_p.add_argument("--bind-password", default=DEFAULT_BIND_PASSWORD, help="Bind password") + search_p.add_argument("--filter", required=True, help="LDAP filter") + search_p.add_argument("--base", help=f"Search base (default: {DEFAULT_BASE_DN})") + + users_p = sub.add_parser("users", help="List all users with group memberships") + users_p.add_argument("--bind-dn", default=DEFAULT_BIND_DN, help="Bind DN") + users_p.add_argument("--bind-password", default=DEFAULT_BIND_PASSWORD, help="Bind password") + + groups_p = sub.add_parser("groups", help="List all groups with members") + groups_p.add_argument("--bind-dn", default=DEFAULT_BIND_DN, help="Bind DN") + groups_p.add_argument("--bind-password", default=DEFAULT_BIND_PASSWORD, help="Bind password") + + args = parser.parse_args() + + commands = { + "check": cmd_check, + "bind": cmd_bind, + "search": cmd_search, + "users": cmd_users, + "groups": cmd_groups, + } + + commands[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/infra/tools/mssql_tool.py b/infra/tools/mssql_tool.py new file mode 100644 index 0000000..2cd1b36 --- /dev/null +++ b/infra/tools/mssql_tool.py @@ -0,0 +1,204 @@ +#!/usr/bin/env python3 +"""MS SQL client tool for ScadaLink test infrastructure.""" + +import argparse +import sys + +import pymssql + + +DEFAULT_HOST = "localhost" +DEFAULT_PORT = 1433 +DEFAULT_USER = "sa" +DEFAULT_PASSWORD = "ScadaLink_Dev1!" +EXPECTED_DBS = ["ScadaLinkConfig", "ScadaLinkMachineData"] + + +def get_connection(args, database=None): + """Create and return a database connection.""" + return pymssql.connect( + server=args.host, + port=args.port, + user=args.user, + password=args.password, + database=database or "master", + ) + + +def cmd_check(args): + """Connect and list databases, verify expected DBs exist.""" + try: + conn = get_connection(args) + cursor = conn.cursor() + cursor.execute("SELECT name FROM sys.databases ORDER BY name") + databases = [row[0] for row in cursor.fetchall()] + + print(f"Connected to: {args.host}:{args.port}") + print(f"Databases ({len(databases)}):") + for db in databases: + marker = " <-- expected" if db in EXPECTED_DBS else "" + print(f" {db}{marker}") + + missing = [db for db in EXPECTED_DBS if db not in databases] + if missing: + print(f"\nMissing expected databases: {', '.join(missing)}") + print("Run: python infra/tools/mssql_tool.py setup --script infra/mssql/setup.sql") + sys.exit(1) + else: + print("\nAll expected databases present.") + + cursor.close() + conn.close() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_setup(args): + """Execute a SQL script file.""" + try: + with open(args.script, "r") as f: + sql = f.read() + except FileNotFoundError: + print(f"Error: script not found: {args.script}", file=sys.stderr) + sys.exit(1) + + try: + conn = get_connection(args) + conn.autocommit(True) + cursor = conn.cursor() + + # Split on GO statements (SQL Server batch separator) + batches = [] + current = [] + for line in sql.splitlines(): + if line.strip().upper() == "GO": + if current: + batches.append("\n".join(current)) + current = [] + else: + current.append(line) + if current: + batches.append("\n".join(current)) + + for i, batch in enumerate(batches, 1): + batch = batch.strip() + if not batch: + continue + cursor.execute(batch) + print(f" Batch {i} executed.") + + print(f"\nScript completed: {args.script} ({len(batches)} batches)") + cursor.close() + conn.close() + except Exception as e: + print(f"Error executing script: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_query(args): + """Run an ad-hoc SQL query and print results.""" + try: + conn = get_connection(args, database=args.database) + cursor = conn.cursor() + cursor.execute(args.sql) + + if cursor.description: + columns = [desc[0] for desc in cursor.description] + rows = cursor.fetchall() + + # Calculate column widths + widths = [len(c) for c in columns] + for row in rows: + for i, val in enumerate(row): + widths[i] = max(widths[i], len(str(val))) + + # Print header + header = " ".join(c.ljust(w) for c, w in zip(columns, widths)) + print(header) + print(" ".join("-" * w for w in widths)) + + # Print rows + for row in rows: + print(" ".join(str(v).ljust(w) for v, w in zip(row, widths))) + + print(f"\n({len(rows)} rows)") + else: + conn.commit() + print(f"Query executed. Rows affected: {cursor.rowcount}") + + cursor.close() + conn.close() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def cmd_tables(args): + """List all tables in a database.""" + try: + conn = get_connection(args, database=args.database) + cursor = conn.cursor() + cursor.execute(""" + SELECT s.name AS [schema], t.name AS [table], + SUM(p.rows) AS [rows] + FROM sys.tables t + JOIN sys.schemas s ON t.schema_id = s.schema_id + JOIN sys.partitions p ON t.object_id = p.object_id AND p.index_id IN (0, 1) + GROUP BY s.name, t.name + ORDER BY s.name, t.name + """) + rows = cursor.fetchall() + + if not rows: + print(f"No tables in {args.database}.") + else: + print(f"Tables in {args.database}:") + print(f"{'Schema':<15} {'Table':<40} {'Rows':<10}") + print("-" * 65) + for schema, table, count in rows: + print(f"{schema:<15} {table:<40} {count:<10}") + print(f"\n({len(rows)} tables)") + + cursor.close() + conn.close() + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +def main(): + parser = argparse.ArgumentParser(description="MS SQL client tool for ScadaLink test infrastructure") + parser.add_argument("--host", default=DEFAULT_HOST, help=f"SQL Server host (default: {DEFAULT_HOST})") + parser.add_argument("--port", type=int, default=DEFAULT_PORT, help=f"Port (default: {DEFAULT_PORT})") + parser.add_argument("--user", default=DEFAULT_USER, help=f"Username (default: {DEFAULT_USER})") + parser.add_argument("--password", default=DEFAULT_PASSWORD, help=f"Password (default: {DEFAULT_PASSWORD})") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("check", help="Connect and verify expected databases") + + setup_p = sub.add_parser("setup", help="Execute a SQL script") + setup_p.add_argument("--script", required=True, help="Path to SQL script file") + + query_p = sub.add_parser("query", help="Run an ad-hoc SQL query") + query_p.add_argument("--database", required=True, help="Database name") + query_p.add_argument("--sql", required=True, help="SQL query to execute") + + tables_p = sub.add_parser("tables", help="List all tables in a database") + tables_p.add_argument("--database", required=True, help="Database name") + + args = parser.parse_args() + + commands = { + "check": cmd_check, + "setup": cmd_setup, + "query": cmd_query, + "tables": cmd_tables, + } + + commands[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/infra/tools/opcua_tool.py b/infra/tools/opcua_tool.py new file mode 100644 index 0000000..dc96d1d --- /dev/null +++ b/infra/tools/opcua_tool.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""OPC UA client tool for ScadaLink test infrastructure.""" + +import argparse +import sys +import time + +from opcua import Client, ua + + +DEFAULT_ENDPOINT = "opc.tcp://localhost:50000" + + +def cmd_check(args): + """Connect and report server status.""" + client = Client(args.endpoint) + try: + client.connect() + server_status = client.get_node(ua.ObjectIds.Server_ServerStatus).get_value() + + print(f"Connected to: {args.endpoint}") + print(f"Server state: {server_status.State}") + print(f"Start time: {server_status.StartTime}") + print(f"Current time: {server_status.CurrentTime}") + print(f"Build info: {server_status.BuildInfo.ProductName} {server_status.BuildInfo.SoftwareVersion}") + + ns = client.get_namespace_array() + print(f"\nNamespaces:") + for i, n in enumerate(ns): + print(f" {i}: {n}") + + endpoints = client.get_endpoints() + print(f"\nEndpoints ({len(endpoints)}):") + for ep in endpoints: + print(f" {ep.EndpointUrl} [{ep.SecurityPolicyUri.split('#')[-1]}]") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + client.disconnect() + + +def cmd_browse(args): + """Browse server node tree.""" + client = Client(args.endpoint) + try: + client.connect() + if args.path: + root = client.get_objects_node() + node = root.get_child(args.path.split(".")) + else: + node = client.get_objects_node() + + browse_name = node.get_browse_name() + print(f"Browsing: {browse_name.Name} ({node.nodeid})") + print() + + children = node.get_children() + for child in children: + name = child.get_browse_name() + node_class = child.get_node_class() + class_name = node_class.name if hasattr(node_class, "name") else str(node_class) + + if node_class == ua.NodeClass.Variable: + try: + value = child.get_value() + dtype = child.get_data_type_as_variant_type() + print(f" {name.Name:<30} [{class_name}] {dtype.name} = {value}") + except Exception: + print(f" {name.Name:<30} [{class_name}]") + else: + print(f" {name.Name:<30} [{class_name}]") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + client.disconnect() + + +def cmd_read(args): + """Read a node value.""" + client = Client(args.endpoint) + try: + client.connect() + node = client.get_node(args.node) + data_value = node.get_data_value() + value = data_value.Value.Value + dtype = node.get_data_type_as_variant_type() + ts = data_value.SourceTimestamp + + print(f"Node: {args.node}") + print(f"Value: {value}") + print(f"Data type: {dtype.name}") + print(f"Timestamp: {ts}") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + client.disconnect() + + +def cmd_write(args): + """Write a value to a node.""" + type_map = { + "Double": (ua.VariantType.Double, float), + "Boolean": (ua.VariantType.Boolean, lambda v: v.lower() in ("true", "1", "yes")), + "UInt32": (ua.VariantType.UInt32, int), + "Int32": (ua.VariantType.Int32, int), + "String": (ua.VariantType.String, str), + } + + if args.type not in type_map: + print(f"Error: unsupported type '{args.type}'. Use one of: {', '.join(type_map)}", file=sys.stderr) + sys.exit(1) + + variant_type, converter = type_map[args.type] + converted_value = converter(args.value) + + client = Client(args.endpoint) + try: + client.connect() + node = client.get_node(args.node) + dv = ua.DataValue(ua.Variant(converted_value, variant_type)) + node.set_value(dv) + print(f"Wrote {converted_value} ({args.type}) to {args.node}") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + client.disconnect() + + +def cmd_monitor(args): + """Subscribe and print value changes.""" + nodes = [n.strip() for n in args.nodes.split(",")] + + client = Client(args.endpoint) + try: + client.connect() + + class Handler: + def datachange_notification(self, node, val, data): + print(f" {node} = {val}") + + handler = Handler() + sub = client.create_subscription(500, handler) + + handles = [] + for node_id in nodes: + node = client.get_node(node_id) + handle = sub.subscribe_data_change(node) + handles.append(handle) + name = node.get_browse_name() + print(f"Monitoring: {name.Name} ({node_id})") + + print(f"\nListening for {args.duration}s...\n") + time.sleep(args.duration) + + for handle in handles: + sub.unsubscribe(handle) + sub.delete() + print("\nDone.") + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + finally: + client.disconnect() + + +def main(): + parser = argparse.ArgumentParser(description="OPC UA client tool for ScadaLink test infrastructure") + parser.add_argument("--endpoint", default=DEFAULT_ENDPOINT, help=f"OPC UA endpoint (default: {DEFAULT_ENDPOINT})") + + sub = parser.add_subparsers(dest="command", required=True) + + sub.add_parser("check", help="Connect and report server status") + + browse_p = sub.add_parser("browse", help="Browse server node tree") + browse_p.add_argument("--path", help="Node path to browse (e.g. 3:OpcPlc.3:Telemetry)") + + read_p = sub.add_parser("read", help="Read a node value") + read_p.add_argument("--node", required=True, help="Node ID (e.g. ns=3;s=Motor.Speed)") + + write_p = sub.add_parser("write", help="Write a value to a node") + write_p.add_argument("--node", required=True, help="Node ID") + write_p.add_argument("--value", required=True, help="Value to write") + write_p.add_argument("--type", required=True, choices=["Double", "Boolean", "UInt32", "Int32", "String"], + help="Data type") + + monitor_p = sub.add_parser("monitor", help="Subscribe and print value changes") + monitor_p.add_argument("--nodes", required=True, help="Comma-separated node IDs") + monitor_p.add_argument("--duration", type=int, default=10, help="Duration in seconds (default: 10)") + + args = parser.parse_args() + + commands = { + "check": cmd_check, + "browse": cmd_browse, + "read": cmd_read, + "write": cmd_write, + "monitor": cmd_monitor, + } + + commands[args.command](args) + + +if __name__ == "__main__": + main() diff --git a/infra/tools/requirements.txt b/infra/tools/requirements.txt new file mode 100644 index 0000000..1a28b42 --- /dev/null +++ b/infra/tools/requirements.txt @@ -0,0 +1,3 @@ +opcua>=0.98.0 +ldap3>=2.9 +pymssql>=2.2.0 diff --git a/test_infra.md b/test_infra.md new file mode 100644 index 0000000..a7ba3fb --- /dev/null +++ b/test_infra.md @@ -0,0 +1,87 @@ +# Test Infrastructure + +This document describes the local Docker-based test infrastructure for ScadaLink development. Three services provide the external dependencies needed to run and test the system locally. + +## Services + +| Service | Image | Port(s) | Config | +|---------|-------|---------|--------| +| OPC UA Server | `mcr.microsoft.com/iotedge/opc-plc:latest` | 50000 (OPC UA), 8080 (web) | `infra/opcua/nodes.json` | +| LDAP Server | `glauth/glauth:latest` | 3893 | `infra/glauth/config.toml` | +| MS SQL 2022 | `mcr.microsoft.com/mssql/server:2022-latest` | 1433 | `infra/mssql/setup.sql` | + +## Quick Start + +```bash +cd infra +docker compose up -d +``` + +After the first startup, run the SQL setup script to create databases and the application user: + +```bash +docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1!' -C \ + -i /docker-entrypoint-initdb.d/setup.sql +``` + +## Per-Service Documentation + +Each service has a dedicated document with configuration details, verification steps, and troubleshooting: + +- [test_infra_opcua.md](test_infra_opcua.md) — OPC UA test server (Azure IoT OPC PLC) +- [test_infra_ldap.md](test_infra_ldap.md) — LDAP test server (GLAuth) +- [test_infra_db.md](test_infra_db.md) — MS SQL 2022 database + +## Connection Strings + +For use in `appsettings.Development.json`: + +```json +{ + "ConnectionStrings": { + "ScadaLinkConfig": "Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true", + "ScadaLinkMachineData": "Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true" + }, + "Ldap": { + "Server": "localhost", + "Port": 3893, + "BaseDN": "dc=scadalink,dc=local", + "UseSsl": false + }, + "OpcUa": { + "EndpointUrl": "opc.tcp://localhost:50000" + } +} +``` + +## Stopping & Teardown + +```bash +cd infra +docker compose down # stop containers, preserve SQL data volume +docker compose stop opcua # stop a single service (also: ldap, mssql) +``` + +**Full teardown** (removes volumes, optionally images and venv): +```bash +cd infra +./teardown.sh # stop containers + delete SQL data volume +./teardown.sh --images # also remove downloaded Docker images +./teardown.sh --all # also remove the Python venv +``` + +After a full teardown, the next `docker compose up -d` starts fresh — re-run the SQL setup script. + +## Files + +``` +infra/ + docker-compose.yml # All three services + teardown.sh # Teardown script (volumes, images, venv) + glauth/config.toml # LDAP users and groups + mssql/setup.sql # Database and user creation + opcua/nodes.json # Custom OPC UA tag definitions + tools/ # Python CLI tools (opcua, ldap, mssql) + README.md # Quick-start for the infra folder +``` diff --git a/test_infra_db.md b/test_infra_db.md new file mode 100644 index 0000000..448137e --- /dev/null +++ b/test_infra_db.md @@ -0,0 +1,126 @@ +# Test Infrastructure: MS SQL 2022 Database + +## Overview + +The test database uses Microsoft SQL Server 2022 Developer Edition running in Docker. It provides two empty databases for ScadaLink — schema creation is handled by EF Core migrations at application startup (dev mode). + +## Image & Ports + +- **Image**: `mcr.microsoft.com/mssql/server:2022-latest` +- **Port**: 1433 +- **Edition**: Developer (free, full-featured) + +## Credentials + +| Account | Username | Password | Purpose | +|---------|----------|----------|---------| +| SA | `sa` | `ScadaLink_Dev1!` | Server admin (setup only) | +| App | `scadalink_app` | `ScadaLink_Dev1!` | Application login (db_owner on both databases) | + +## Databases + +| Database | Purpose | +|----------|---------| +| `ScadaLinkConfig` | Configuration Database component — templates, deployments, users, audit log | +| `ScadaLinkMachineData` | Machine/operational data storage | + +Both databases are created empty by `infra/mssql/setup.sql`. EF Core migrations populate the schema. + +## Data Persistence + +SQL data is stored in the named Docker volume `scadalink-mssql-data`. Data survives container restarts and `docker compose down`. To reset the database completely: + +```bash +docker compose down -v +docker compose up -d +# Re-run setup.sql after the container starts +``` + +## First-Time Setup + +After the first `docker compose up -d`, run the setup script: + +```bash +docker exec -i scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1!' -C \ + -i /docker-entrypoint-initdb.d/setup.sql +``` + +This creates the databases and the `scadalink_app` login. You only need to run this once (or again after deleting the volume). + +## Connection Strings + +For `appsettings.Development.json`: + +``` +Server=localhost,1433;Database=ScadaLinkConfig;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true +``` + +``` +Server=localhost,1433;Database=ScadaLinkMachineData;User Id=scadalink_app;Password=ScadaLink_Dev1!;TrustServerCertificate=true +``` + +## Verification + +1. Check the container is running: + +```bash +docker ps --filter name=scadalink-mssql +``` + +2. Query using `sqlcmd` inside the container: + +```bash +docker exec -it scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U sa -P 'ScadaLink_Dev1!' -C \ + -Q "SELECT name FROM sys.databases" +``` + +3. Verify the app login: + +```bash +docker exec -it scadalink-mssql /opt/mssql-tools18/bin/sqlcmd \ + -S localhost -U scadalink_app -P 'ScadaLink_Dev1!' -C \ + -d ScadaLinkConfig \ + -Q "SELECT DB_NAME()" +``` + +## CLI Tool + +The `infra/tools/mssql_tool.py` script provides a convenient CLI for interacting with the SQL Server. + +**Install dependencies** (one-time): +```bash +pip install -r infra/tools/requirements.txt +``` + +**Commands**: + +```bash +# Check connectivity and verify expected databases exist +python infra/tools/mssql_tool.py check + +# Run the first-time setup script (uses autocommit mode for CREATE DATABASE) +python infra/tools/mssql_tool.py setup --script infra/mssql/setup.sql + +# List tables in a database +python infra/tools/mssql_tool.py tables --database ScadaLinkConfig + +# Run an ad-hoc query +python infra/tools/mssql_tool.py query --database ScadaLinkConfig --sql "SELECT name FROM sys.tables" +``` + +Use `--host`, `--port`, `--user`, `--password` to override defaults (localhost:1433, sa, ScadaLink_Dev1!). Run with `--help` for full usage. + +## Relevance to ScadaLink Components + +- **Configuration Database** — primary consumer; EF Core context targets `ScadaLinkConfig`. +- **Deployment Manager** — reads/writes deployment records in `ScadaLinkConfig`. +- **Template Engine** — reads/writes template definitions in `ScadaLinkConfig`. +- **Security & Auth** — user/role data stored in `ScadaLinkConfig`. + +## Notes + +- The `sa` password must meet SQL Server complexity requirements (uppercase, lowercase, digit, special character, 8+ characters). +- If the container fails to start, check Docker has at least 2GB RAM allocated (SQL Server minimum requirement). +- The setup script is idempotent — safe to run multiple times. diff --git a/test_infra_ldap.md b/test_infra_ldap.md new file mode 100644 index 0000000..c49efa7 --- /dev/null +++ b/test_infra_ldap.md @@ -0,0 +1,127 @@ +# Test Infrastructure: LDAP Server + +## Overview + +The test LDAP server uses [GLAuth](https://glauth.github.io/), a lightweight LDAP server backed by a TOML config file. It provides test users and groups that map to ScadaLink's role-based authorization model. + +## Image & Ports + +- **Image**: `glauth/glauth:latest` +- **LDAP port**: 3893 (plain LDAP, no TLS — dev only) + +## Base DN + +``` +dc=scadalink,dc=local +``` + +## Test Users + +All users have the password `password`. + +| Username | Email | Primary Group | Additional Groups | ScadaLink Role | +|----------|-------|---------------|-------------------|----------------| +| `admin` | admin@scadalink.local | SCADA-Admins | — | Full administrator | +| `designer` | designer@scadalink.local | SCADA-Designers | — | Template designer | +| `deployer` | deployer@scadalink.local | SCADA-Deploy-All | — | Deploy to all sites | +| `site-deployer` | site-deployer@scadalink.local | SCADA-Deploy-SiteA | — | Deploy to SiteA only | +| `multi-role` | multi-role@scadalink.local | SCADA-Admins | SCADA-Designers, SCADA-Deploy-All | Multiple roles | + +## Groups + +| Group | GID | Purpose | +|-------|-----|---------| +| SCADA-Admins | 5501 | Full administrative access | +| SCADA-Designers | 5502 | Template creation and editing | +| SCADA-Deploy-All | 5503 | Deploy to any site | +| SCADA-Deploy-SiteA | 5504 | Deploy to SiteA only (site-scoped) | + +## User DNs + +Users bind with their full DN, which includes the primary group as an OU: + +``` +cn=,ou=,ou=users,dc=scadalink,dc=local +``` + +For example: `cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local` + +The full DNs for all test users: + +| Username | Full DN | +|----------|---------| +| `admin` | `cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local` | +| `designer` | `cn=designer,ou=SCADA-Designers,ou=users,dc=scadalink,dc=local` | +| `deployer` | `cn=deployer,ou=SCADA-Deploy-All,ou=users,dc=scadalink,dc=local` | +| `site-deployer` | `cn=site-deployer,ou=SCADA-Deploy-SiteA,ou=users,dc=scadalink,dc=local` | +| `multi-role` | `cn=multi-role,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local` | + +## Verification + +1. Check the container is running: + +```bash +docker ps --filter name=scadalink-ldap +``` + +2. Test a user bind with `ldapsearch`: + +```bash +ldapsearch -H ldap://localhost:3893 \ + -D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local" \ + -w password \ + -b "dc=scadalink,dc=local" \ + "(objectClass=*)" +``` + +3. Search for group membership: + +```bash +ldapsearch -H ldap://localhost:3893 \ + -D "cn=admin,ou=SCADA-Admins,ou=users,dc=scadalink,dc=local" \ + -w password \ + -b "dc=scadalink,dc=local" \ + "(cn=multi-role)" +``` + +## CLI Tool + +The `infra/tools/ldap_tool.py` script provides a convenient CLI for interacting with the LDAP server. + +**Install dependencies** (one-time): +```bash +pip install -r infra/tools/requirements.txt +``` + +**Commands**: + +```bash +# Check LDAP connectivity and list entries +python infra/tools/ldap_tool.py check + +# Test user authentication +python infra/tools/ldap_tool.py bind --user designer --password password + +# List all users with group memberships +python infra/tools/ldap_tool.py users + +# List all groups with members +python infra/tools/ldap_tool.py groups + +# Search with an arbitrary LDAP filter +python infra/tools/ldap_tool.py search --filter "(cn=multi-role)" +``` + +Use `--host` and `--port` to override defaults (localhost:3893). Run with `--help` for full usage. + +## Relevance to ScadaLink Components + +- **Security & Auth** — test LDAP bind authentication, group-to-role mapping, and multi-group resolution. +- **Central UI** — test login flows with different role combinations. + +## Notes + +- GLAuth uses plain LDAP on port 3893. ScadaLink's Security & Auth component requires LDAPS/StartTLS in production. For dev testing, configure the LDAP client to allow plaintext connections. +- To add users or groups, edit `infra/glauth/config.toml` locally and restart the container: `docker compose restart ldap`. Note that the file is named `config.toml` on the host but is mounted into the container as `/app/config/config.cfg` (the path GLAuth expects). +- The `admin` user is configured with `[[users.capabilities]]` (`action = "search"`, `object = "*"`) in the GLAuth config. This grants the admin account permission to perform LDAP search operations, which is required for user/group lookups. +- Anonymous bind is not allowed. All LDAP operations (including searches) require an authenticated bind. Use the `admin` account for search operations. diff --git a/test_infra_opcua.md b/test_infra_opcua.md new file mode 100644 index 0000000..c03d5bd --- /dev/null +++ b/test_infra_opcua.md @@ -0,0 +1,97 @@ +# Test Infrastructure: OPC UA Server + +## Overview + +The test OPC UA server uses [Azure IoT OPC PLC](https://github.com/Azure-Samples/iot-edge-opc-plc), a simulated OPC UA server that generates realistic data. It is configured with custom nodes that match ScadaLink attribute patterns. + +## Image & Ports + +- **Image**: `mcr.microsoft.com/iotedge/opc-plc:latest` +- **OPC UA endpoint**: `opc.tcp://localhost:50000` +- **Web/config UI**: `http://localhost:8080` + +## Startup Flags + +``` +--autoaccept Accept all client certificates +--unsecuretransport Enable plain (non-TLS) OPC UA connections for dev tools +--sph Show PLC heartbeat on console +--sn=5 --sr=10 --st=uint 5 slow-changing nodes (10s cycle, uint) +--fn=5 --fr=1 --ft=uint 5 fast-changing nodes (1s cycle, uint) +--gn=5 5 stepping nodes +--nf=/app/config/nodes.json Custom node definitions +--pn=50000 Listen port +``` + +## Custom Nodes + +The file `infra/opcua/nodes.json` defines a single `ConfigFolder` object (not an array) with a root "ScadaLink" folder containing four equipment subfolders. Tags match typical ScadaLink instance attribute patterns: + +| Folder | Tags | Types | +|--------|------|-------| +| Motor | Speed, Temperature, Current, Running, FaultCode | Double, Boolean, UInt32 | +| Pump | FlowRate, Pressure, Running | Double, Boolean | +| Tank | Level, Temperature, HighLevel, LowLevel | Double, Boolean | +| Valve | Position, Command | Double, UInt32 | + +All custom nodes hold their initial/default values (0 for numerics, false for booleans) until written. OPC PLC's custom node format does not support random value generation for these nodes. + +Custom nodes live in namespace 3 (`http://microsoft.com/Opc/OpcPlc/`). Node IDs follow the pattern `ns=3;s=.` (e.g., `ns=3;s=Motor.Speed`). + +The browse path from the Objects root is: `OpcPlc > ScadaLink > Motor|Pump|Tank|Valve`. + +## Verification + +1. Check the container is running: + +```bash +docker ps --filter name=scadalink-opcua +``` + +2. Verify the OPC UA endpoint using any OPC UA client (e.g., UaExpert, opcua-commander): + +```bash +# Using opcua-commander (npm install -g opcua-commander) +opcua-commander -e opc.tcp://localhost:50000 +``` + +3. Check the web UI at `http://localhost:8080` for server status and node listing. + +## CLI Tool + +The `infra/tools/opcua_tool.py` script provides a convenient CLI for interacting with the OPC UA server. + +**Install dependencies** (one-time): +```bash +pip install -r infra/tools/requirements.txt +``` + +**Commands**: + +```bash +# Check server status, namespaces, and endpoints +python infra/tools/opcua_tool.py check + +# Browse the Objects folder (top-level) +python infra/tools/opcua_tool.py browse + +# Browse a specific equipment folder +python infra/tools/opcua_tool.py browse --path "3:OpcPlc.3:ScadaLink.3:Motor" + +# Read a tag value +python infra/tools/opcua_tool.py read --node "ns=3;s=Motor.Speed" + +# Write a value to a tag +python infra/tools/opcua_tool.py write --node "ns=3;s=Motor.Running" --value true --type Boolean + +# Monitor value changes for 15 seconds +python infra/tools/opcua_tool.py monitor --nodes "ns=3;s=Motor.Speed,ns=3;s=Pump.FlowRate" --duration 15 +``` + +Use `--endpoint` to override the default endpoint (`opc.tcp://localhost:50000`). Run with `--help` for full usage. + +## Relevance to ScadaLink Components + +- **Data Connection Layer** — connect to this server to test OPC UA subscription, read/write, and reconnection behavior. +- **Site Runtime / Instance Actors** — deploy instances with tag mappings pointing at these nodes. +- **Template Engine** — design templates with attributes matching the Motor/Pump/Tank/Valve folder structure.