From d2a9c87a258ab7c5104b16590424b06bbab4f4ed Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 15 Jan 2026 17:06:41 -0500 Subject: [PATCH 01/21] docs: Add OPC-UA server configuration design documentation Add comprehensive design documentation for the OPC-UA server configuration feature including: - Design overview with architecture and configuration flow - UI screen specifications for all 5 configuration tabs - JSON configuration mapping between editor and runtime formats - Implementation phases with detailed deliverables The OPC-UA server configuration will allow users to expose PLC variables through the OPC-UA protocol with support for: - Multiple security profiles (None, Sign, SignAndEncrypt) - User authentication (password and certificate-based) - Role-based access control (viewer, operator, engineer) - Complex data types (structures, arrays, nested function blocks) Co-Authored-By: Claude Opus 4.5 --- .../01-design-overview.md | 414 ++++++++ .../02-ui-screen-specifications.md | 818 +++++++++++++++ .../03-json-configuration-mapping.md | 959 +++++++++++++++++ .../04-implementation-phases.md | 973 ++++++++++++++++++ docs/opcua-server-configuration/README.md | 39 + 5 files changed, 3203 insertions(+) create mode 100644 docs/opcua-server-configuration/01-design-overview.md create mode 100644 docs/opcua-server-configuration/02-ui-screen-specifications.md create mode 100644 docs/opcua-server-configuration/03-json-configuration-mapping.md create mode 100644 docs/opcua-server-configuration/04-implementation-phases.md create mode 100644 docs/opcua-server-configuration/README.md diff --git a/docs/opcua-server-configuration/01-design-overview.md b/docs/opcua-server-configuration/01-design-overview.md new file mode 100644 index 000000000..7edaa6da3 --- /dev/null +++ b/docs/opcua-server-configuration/01-design-overview.md @@ -0,0 +1,414 @@ +# OPC-UA Server Configuration - Design Overview + +## 1. Architecture Overview + +### 1.1 System Context + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ OpenPLC Editor │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ OPC-UA Configuration UI │ │ +│ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │ +│ │ │ General │ │ Security │ │ Users │ │ Address Space │ │ │ +│ │ │ Settings │ │ Profiles │ │ │ │ (Variables) │ │ │ +│ │ └──────────┘ └──────────┘ └──────────┘ └──────────────────┘ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Project State (Zustand Store) │ │ +│ │ - OPC-UA config stored WITHOUT indices │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ │ +│ ▼ (on compile) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Compiler Module │ │ +│ │ 1. xml2st generates debug.c (with variable indices) │ │ +│ │ 2. Parse debug.c to get index mapping │ │ +│ │ 3. Resolve variable references → indices │ │ +│ │ 4. Generate opcua.json with resolved indices │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────────────────┐ +│ OpenPLC Runtime │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ OPC-UA Plugin │ │ +│ │ Reads opcua.json configuration │ │ +│ │ Exposes variables via OPC-UA protocol │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 1.2 Configuration Flow + +The OPC-UA configuration follows a two-phase approach: + +#### Phase 1: Configuration Time (No Compilation Required) + +1. User opens OPC-UA server configuration from the Servers panel +2. User configures server settings, security profiles, users +3. User selects variables from the project's POU tree structure +4. User configures OPC-UA properties for each selected variable (node ID, permissions, etc.) +5. Configuration is saved in the project file **without variable indices** + +#### Phase 2: Compilation Time (Index Resolution) + +1. User compiles the project +2. xml2st generates `debug.c` with all variable indices +3. Compiler parses `debug.c` using existing `parseDebugFile()` function +4. Compiler matches user's selected variables to their debug indices +5. Compiler generates `opcua.json` with fully resolved indices +6. `opcua.json` is included in the compiled project package + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Configuration Flow Diagram │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ Project POUs │ │ OPC-UA Config UI │ │ +│ │ (Programs, FBs, │ ───► │ (Select Variables │ │ +│ │ Global Variables) │ │ Configure Props) │ │ +│ └────────────────────┘ └─────────┬──────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────┐ │ +│ │ Project File │ │ +│ │ (Config WITHOUT │ │ +│ │ indices) │ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ▼ [User clicks Compile] │ +│ ┌────────────────────┐ │ +│ │ xml2st │ │ +│ │ (Generates debug.c)│ │ +│ └─────────┬──────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ parseDebugFile() │ ◄─── │ debug.c │ │ +│ │ (Extract indices) │ │ (Variable indices) │ │ +│ └─────────┬──────────┘ └────────────────────┘ │ +│ │ │ +│ ▼ │ +│ ┌────────────────────┐ ┌────────────────────┐ │ +│ │ Resolve indices │ ───► │ opcua.json │ │ +│ │ for each variable │ │ (WITH indices) │ │ +│ └────────────────────┘ └────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +## 2. Integration with Existing Systems + +### 2.1 Debugger Integration + +The OPC-UA plugin uses the **same debug variable indices** as the OpenPLC debugger. This means: + +- We reuse `parseDebugFile()` from `src/renderer/utils/parse-debug-file.ts` +- We reuse the path matching logic from the debugger implementation +- Variable indices are consistent between debugger and OPC-UA access + +### 2.2 Project Structure Integration + +OPC-UA configuration follows the same patterns as Modbus and S7Comm: + +- Configuration stored in `project.data.servers[]` array +- Protocol discriminated by `server.protocol === 'opcua'` +- Configuration in `server.opcuaServerConfig` object + +### 2.3 Compiler Integration + +The compiler module generates `opcua.json` similar to how it generates `modbus_slave.json` and `s7comm.json`: + +```typescript +// In compiler-module.ts +async handleGenerateOpcUaConfig(sourceTargetFolderPath, projectData) { + const opcuaConfig = generateOpcUaConfig( + projectData.servers, + parsedDebugVariables // From debug.c parsing + ) + if (opcuaConfig) { + await writeFile( + join(sourceTargetFolderPath, 'conf', 'opcua.json'), + opcuaConfig + ) + } +} +``` + +## 3. Data Model + +### 3.1 Editor Data Model (Stored in Project - No Indices) + +```typescript +interface OpcUaServerConfig { + server: { + enabled: boolean + name: string + applicationUri: string + productUri: string + bindAddress: string + port: number + endpointPath: string + } + + securityProfiles: OpcUaSecurityProfile[] + + security: { + serverCertificateStrategy: 'auto_self_signed' | 'custom' + serverCertificateCustom: string | null + serverPrivateKeyCustom: string | null + trustedClientCertificates: OpcUaTrustedCertificate[] + } + + users: OpcUaUser[] + + cycleTimeMs: number + + addressSpace: { + namespaceUri: string + nodes: OpcUaNodeConfig[] // Hierarchical tree of selected nodes + } +} + +// Variable/Node configuration WITHOUT index +interface OpcUaNodeConfig { + id: string // UUID + + // Reference to PLC variable (resolved to index at compile time) + pouName: string // "main", "GVL", "FB_Motor" + variablePath: string // "MOTOR_SPEED", "TON0.ET", "SENSOR.temperature" + variableType: string // IEC type: "INT", "BOOL", "REAL", etc. + + // OPC-UA node configuration + nodeId: string // "PLC.Main.MotorSpeed" + browseName: string + displayName: string + description: string + initialValue: any + permissions: { + viewer: 'r' | 'w' | 'rw' + operator: 'r' | 'w' | 'rw' + engineer: 'r' | 'w' | 'rw' + } + + // For complex types + nodeType: 'variable' | 'structure' | 'array' + children?: OpcUaNodeConfig[] // For structures: field configs + arrayLength?: number // For arrays: element count +} +``` + +### 3.2 Runtime Data Model (Generated JSON - With Indices) + +The runtime expects a JSON array with the following structure: + +```typescript +interface OpcUaRuntimeConfig { + name: string // "opcua_server" + protocol: "OPC-UA" + config: { + server: { + name: string + application_uri: string + product_uri: string + endpoint_url: string // Full URL: opc.tcp://host:port/path + security_profiles: RuntimeSecurityProfile[] + } + security: { + server_certificate_strategy: string + server_certificate_custom: string | null + server_private_key_custom: string | null + trusted_client_certificates: { id: string; pem: string }[] + } + users: RuntimeUser[] + cycle_time_ms: number + address_space: { + namespace_uri: string + variables: RuntimeVariable[] // With resolved indices + structures: RuntimeStructure[] // With resolved field indices + arrays: RuntimeArray[] // With resolved start indices + } + } +} +``` + +## 4. Variable Hierarchy Support + +### 4.1 Hierarchical Variable Structure + +PLC programs can have deeply nested variable structures: + +``` +Program: main +├── MOTOR_SPEED (INT) → Simple variable +├── IS_RUNNING (BOOL) → Simple variable +├── MOTOR_CONTROLLER (FB_MotorControl) → Function Block instance +│ ├── ENABLE (BOOL) → FB input +│ ├── SPEED_SETPOINT (INT) → FB input +│ ├── ACTUAL_SPEED (INT) → FB output +│ └── PID_CTRL (FB_PID) → Nested FB +│ ├── KP (REAL) +│ ├── KI (REAL) +│ └── OUTPUT (REAL) +├── SENSOR_DATA (T_SensorData) → Structure +│ ├── temperature (REAL) → Struct field +│ ├── pressure (REAL) → Struct field +│ └── status (T_Status) → Nested structure +│ ├── is_valid (BOOL) +│ └── error_code (INT) +├── TEMPERATURES (ARRAY[1..10] OF REAL) → Array +└── SENSOR_ARRAY (ARRAY[1..5] OF T_SensorData) → Array of structures + └── [1] (T_SensorData) + ├── temperature (REAL) + └── ... +``` + +### 4.2 Debug Path Format + +The debugger uses specific path formats for each variable type: + +| Type | Debug Path Format | Example | +|------|-------------------|---------| +| Simple | `RES0__INSTANCE.VARNAME` | `RES0__INSTANCE0.MOTOR_SPEED` | +| FB Variable | `RES0__INSTANCE.FBVAR.FIELD` | `RES0__INSTANCE0.MOTOR_CONTROLLER.ENABLE` | +| Nested FB | `RES0__INSTANCE.FB1.FB2.FIELD` | `RES0__INSTANCE0.MOTOR_CONTROLLER.PID_CTRL.KP` | +| Struct Field | `RES0__INSTANCE.VAR.value.FIELD` | `RES0__INSTANCE0.SENSOR_DATA.value.temperature` | +| Nested Struct | `RES0__INSTANCE.VAR.value.NESTED.value.FIELD` | `RES0__INSTANCE0.SENSOR_DATA.value.status.value.is_valid` | +| Array Element | `RES0__INSTANCE.VAR.value.table[i]` | `RES0__INSTANCE0.TEMPERATURES.value.table[0]` | +| Global | `CONFIG0__VARNAME` | `CONFIG0__SYSTEM_STATE` | + +### 4.3 Index Resolution at Compile Time + +```typescript +const resolveVariableIndex = ( + pouName: string, + variablePath: string, + debugVariables: DebugVariable[] +): number => { + // Build the debug path based on variable type + const debugPath = buildDebugPath(pouName, variablePath) + + // Find matching entry in parsed debug.c + const debugVar = debugVariables.find(dv => + dv.name.toUpperCase() === debugPath.toUpperCase() + ) + + if (!debugVar) { + throw new Error(`Cannot resolve index for ${pouName}:${variablePath}`) + } + + return debugVar.index +} + +const buildDebugPath = (pouName: string, variablePath: string): string => { + // Handle global variables + if (pouName === 'GVL' || pouName === 'CONFIG') { + return `CONFIG0__${variablePath.toUpperCase()}` + } + + // Handle program/FB instance variables + // The instance name format depends on resource configuration + return `RES0__INSTANCE0.${variablePath.toUpperCase()}` +} +``` + +## 5. Security Model + +### 5.1 Security Profiles + +OPC-UA supports multiple security profiles that can be enabled simultaneously: + +| Profile | Security Policy | Security Mode | Auth Methods | +|---------|-----------------|---------------|--------------| +| Insecure | None | None | Anonymous | +| Signed | Basic256Sha256 | Sign | Username, Certificate | +| Encrypted | Basic256Sha256 | SignAndEncrypt | Username, Certificate | + +### 5.2 User Roles and Permissions + +Three user roles with different access levels: + +| Role | Description | Default Variable Access | +|------|-------------|------------------------| +| Viewer | Read-only monitoring | Read only | +| Operator | Can modify operational variables | Read/Write (configurable) | +| Engineer | Full administrative access | Read/Write | + +Each variable can have custom permissions per role. + +## 6. File Structure + +``` +src/ +├── types/PLC/ +│ └── open-plc.ts # Add OPC-UA type schemas +├── utils/ +│ └── opcua/ +│ ├── generate-opcua-config.ts # JSON generator with index resolution +│ ├── bcrypt-utils.ts # Password hashing utilities +│ └── index.ts +├── renderer/ +│ ├── utils/ +│ │ └── parse-debug-file.ts # REUSE for index resolution +│ ├── store/slices/project/ +│ │ └── slice.ts # Add OPC-UA actions +│ └── components/_features/[workspace]/editor/ +│ └── server/ +│ └── opcua-server/ +│ ├── index.tsx # Main tabbed component +│ ├── tabs/ +│ │ ├── general-settings.tsx +│ │ ├── security-profiles.tsx +│ │ ├── certificates.tsx +│ │ ├── users.tsx +│ │ └── address-space.tsx +│ └── components/ +│ ├── variable-tree.tsx +│ ├── variable-config-modal.tsx +│ └── ... +└── main/modules/compiler/ + └── compiler-module.ts # Add OPC-UA JSON generation +``` + +## 7. Key Design Decisions + +### 7.1 Index Resolution at Compile Time + +**Decision**: Store variable references (pouName + variablePath) in project, resolve indices only during compilation. + +**Rationale**: +- Users can configure OPC-UA without building first +- Indices may change when program is modified +- Consistent with how debugger works + +### 7.2 Reuse Debugger Infrastructure + +**Decision**: Reuse `parseDebugFile()` and path matching logic from debugger. + +**Rationale**: +- OPC-UA plugin uses same debug indices as debugger +- Proven, tested code +- Maintains consistency + +### 7.3 Tabbed UI Interface + +**Decision**: Use tabbed interface instead of single scrollable page. + +**Rationale**: +- OPC-UA configuration is significantly more complex than Modbus/S7Comm +- Tabs help organize related settings +- Reduces cognitive load on users + +### 7.4 Tree-Based Variable Selection + +**Decision**: Display all project variables in a tree structure, allowing selection of individual leaves or entire branches (structures/arrays). + +**Rationale**: +- Supports deeply nested hierarchies (FB inside FB inside Program) +- Intuitive selection of complex types +- Consistent with how POUs are structured diff --git a/docs/opcua-server-configuration/02-ui-screen-specifications.md b/docs/opcua-server-configuration/02-ui-screen-specifications.md new file mode 100644 index 000000000..353ed3fb8 --- /dev/null +++ b/docs/opcua-server-configuration/02-ui-screen-specifications.md @@ -0,0 +1,818 @@ +# OPC-UA Server Configuration - UI Screen Specifications + +## 1. Overall Layout + +The OPC-UA server configuration uses a **tabbed interface** with 5 main tabs: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ OPC-UA Server Configuration [X] Close │ +├─────────────────────────────────────────────────────────────────────────┤ +│ ┌──────────┬──────────┬──────────┬──────────┬───────────────────────┐ │ +│ │ General │ Security │ Users │ Certifi- │ Address Space │ │ +│ │ Settings │ Profiles │ │ cates │ │ │ +│ └──────────┴──────────┴──────────┴──────────┴───────────────────────┘ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ │ │ +│ │ [Active Tab Content] │ │ +│ │ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 2. Tab 1: General Settings + +### 2.1 Layout + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ General Settings │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Enable OPC-UA Server [Toggle: Off] │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Server Identity │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Server Name │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ OpenPLC OPC UA Server │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Application URI │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ urn:openplc:opcua:server │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Unique identifier for this application instance) │ +│ │ +│ Product URI │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ urn:openplc:runtime │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Identifier for the product type) │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Network Configuration │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Bind Address Port │ +│ ┌─────────────────────────┐ ┌─────────────────────────┐ │ +│ │ 0.0.0.0 [▼]│ │ 4840 │ │ +│ └─────────────────────────┘ └─────────────────────────┘ │ +│ (0.0.0.0 = all interfaces) (Default: 4840) │ +│ │ +│ Endpoint Path │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ /openplc/opcua │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Full Endpoint URL: │ +│ opc.tcp://0.0.0.0:4840/openplc/opcua │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Performance │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Synchronization Cycle Time (ms) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 100 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Lower values = faster updates, higher CPU usage. Range: 10-10000) │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 2.2 Fields Specification + +| Field | Type | Default | Validation | Description | +|-------|------|---------|------------|-------------| +| enabled | boolean | false | - | Master switch for OPC-UA server | +| name | string | "OpenPLC OPC UA Server" | max 128 chars | Human-readable server name | +| applicationUri | string | "urn:openplc:opcua:server" | valid URI | Unique application identifier | +| productUri | string | "urn:openplc:runtime" | valid URI | Product type identifier | +| bindAddress | string | "0.0.0.0" | valid IP | Network interface to bind | +| port | number | 4840 | 1-65535 | TCP port number | +| endpointPath | string | "/openplc/opcua" | starts with / | URL path component | +| cycleTimeMs | number | 100 | 10-10000 | Sync interval in milliseconds | + +--- + +## 3. Tab 2: Security Profiles + +### 3.1 Layout + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Security Profiles │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Configure which security profiles clients can use to connect. │ +│ At least one profile must be enabled. │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [+] Add Security Profile │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [✓] Insecure (No Security) [Edit] [Del] │ │ +│ │ Policy: None | Mode: None │ │ +│ │ Authentication: Anonymous │ │ +│ │ ⚠ Warning: No encryption or authentication. Use only for │ │ +│ │ development/testing. │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [ ] Signed [Edit] [Del] │ │ +│ │ Policy: Basic256Sha256 | Mode: Sign │ │ +│ │ Authentication: Username, Certificate │ │ +│ │ Messages are signed but not encrypted. │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [ ] Signed & Encrypted [Edit] [Del] │ │ +│ │ Policy: Basic256Sha256 | Mode: SignAndEncrypt │ │ +│ │ Authentication: Username, Certificate │ │ +│ │ Full security: messages are signed and encrypted. │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Note: Security profiles with Certificate authentication require │ +│ trusted client certificates to be configured in the Certificates tab. │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.2 Add/Edit Security Profile Modal + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Add Security Profile [X] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Profile Name │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ signed_encrypted │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Unique identifier for this profile) │ +│ │ +│ Enabled [Toggle: On] │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Security Settings │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ Security Policy │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Basic256Sha256 [▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ Options: None, Basic128Rsa15, Basic256, Basic256Sha256 │ +│ │ +│ Security Mode │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ SignAndEncrypt [▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ Options: None, Sign, SignAndEncrypt │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Authentication Methods │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [ ] Anonymous │ +│ (Only available with Security Policy: None) │ +│ │ +│ [✓] Username / Password │ +│ (Users must be configured in the Users tab) │ +│ │ +│ [✓] Certificate │ +│ (Client certificates must be added to trusted list) │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Cancel] [Save Profile] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 3.3 Validation Rules + +- At least one security profile must be enabled +- "Anonymous" authentication only available when Security Policy = "None" +- Security Mode "None" requires Security Policy "None" +- Profile names must be unique + +--- + +## 4. Tab 3: Users + +### 4.1 Layout + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ User Accounts │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Configure user accounts for OPC-UA client authentication. │ +│ At least one user is required when Username authentication is enabled. │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [+] Add User │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 👤 viewer [Edit] [Del] │ │ +│ │ Type: Password Authentication │ │ +│ │ Role: Viewer (Read-only access) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 👤 operator [Edit] [Del] │ │ +│ │ Type: Password Authentication │ │ +│ │ Role: Operator (Read/Write per variable permissions) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 👤 engineer [Edit] [Del] │ │ +│ │ Type: Password Authentication │ │ +│ │ Role: Engineer (Full access) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 🔐 engineer_cert [Edit] [Del] │ │ +│ │ Type: Certificate Authentication │ │ +│ │ Certificate: engineer_client │ │ +│ │ Role: Engineer (Full access) │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Role Descriptions: │ +│ • Viewer: Can only read variable values (monitoring) │ +│ • Operator: Can read/write based on per-variable permissions │ +│ • Engineer: Full administrative access to all variables │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.2 Add/Edit User Modal + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Add User [X] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Authentication Type │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Password [▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ Options: Password, Certificate │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Password Authentication (visible when type=Password)│ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Username │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ operator │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Password │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ •••••••• [👁]│ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Confirm Password │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ •••••••• [👁]│ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Certificate Authentication (visible when type=Certificate)│ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Client Certificate │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ engineer_client [▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Select from trusted certificates configured in Certificates tab) │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ User Role │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Role │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Operator [▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ Options: Viewer, Operator, Engineer │ +│ │ +│ Role Permissions: │ +│ • Viewer: Read-only access to all variables │ +│ • Operator: Read/Write based on per-variable permission settings │ +│ • Engineer: Full administrative access │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Cancel] [Save User] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 4.3 Password Handling + +Passwords are hashed using bcrypt before storage: + +```typescript +import bcrypt from 'bcryptjs' + +const hashPassword = async (password: string): Promise => { + const salt = await bcrypt.genSalt(12) + return bcrypt.hash(password, salt) +} +``` + +The plain text password is **never** stored in the project file. + +--- + +## 5. Tab 4: Certificates + +### 5.1 Layout + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Certificate Management │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Server Certificate │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Certificate Strategy │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Auto-generate Self-Signed [▼] │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ Options: Auto-generate Self-Signed, Use Custom Certificate │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Custom Certificate (visible when strategy=custom) │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ Server Certificate (PEM format) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ -----BEGIN CERTIFICATE----- │ │ +│ │ MIIEpDCCAowCCQC7... │ │ +│ │ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ [Browse File...] │ +│ │ +│ Server Private Key (PEM format) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ -----BEGIN PRIVATE KEY----- │ │ +│ │ MIIEvgIBADANBg... │ │ +│ │ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ [Browse File...] │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Trusted Client Certificates │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Client certificates that are trusted for certificate authentication. │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ [+] Add Trusted Certificate │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 📜 engineer_client [View] [Del] │ │ +│ │ Subject: CN=Engineer Client, O=MyCompany │ │ +│ │ Valid: 2024-01-01 to 2025-12-31 │ │ +│ │ Fingerprint: A1:B2:C3:D4:E5:F6:... │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 📜 scada_client [View] [Del] │ │ +│ │ Subject: CN=SCADA System, O=Industrial Corp │ │ +│ │ Valid: 2024-06-01 to 2026-06-01 │ │ +│ │ Fingerprint: 12:34:56:78:9A:BC:... │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 Add Trusted Certificate Modal + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Add Trusted Client Certificate [X] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Certificate ID │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ engineer_client │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Unique identifier used to reference this certificate in user config) │ +│ │ +│ Certificate (PEM format) │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ -----BEGIN CERTIFICATE----- │ │ +│ │ MIIEpDCCAowCCQC7... │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ │ │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ [Browse File...] │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Certificate Details (parsed from PEM) │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ Subject: CN=Engineer Client, O=MyCompany │ +│ Issuer: CN=My CA, O=MyCompany │ +│ Valid From: 2024-01-01 00:00:00 UTC │ +│ Valid To: 2025-12-31 23:59:59 UTC │ +│ Fingerprint: A1:B2:C3:D4:E5:F6:78:90:AB:CD:EF:12:34:56:78:90 │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Cancel] [Add Certificate] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +--- + +## 6. Tab 5: Address Space (Variable Selection) + +### 6.1 Overview + +The Address Space tab allows users to select which PLC variables to expose via OPC-UA. It displays **all project variables in a hierarchical tree structure**, supporting: + +- Simple variables (INT, BOOL, REAL, etc.) +- Function Block instances with their internal variables +- Nested Function Blocks (FB inside FB inside Program) +- Structures with fields +- Nested Structures (struct inside struct) +- Arrays of any type +- Arrays of structures + +### 6.2 Main Layout + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Address Space Configuration │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Namespace URI │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ urn:openplc:opcua:namespace │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ Select variables to expose via OPC-UA. You can select individual │ +│ variables (leaves) or entire structures/arrays/function blocks │ +│ (branches). Variable indices are resolved automatically during │ +│ project compilation. │ +│ │ +│ ┌────────────────────────────────┬────────────────────────────────┐ │ +│ │ Available PLC Variables │ Selected for OPC-UA │ │ +│ │ (from project) │ │ │ +│ ├────────────────────────────────┼────────────────────────────────┤ │ +│ │ ▼ 📁 main (Program) │ ▼ PLC.Main.MotorSpeed │ │ +│ │ ├── ☐ MOTOR_SPEED (INT) │ │ main:MOTOR_SPEED │ │ +│ │ ├── ☐ IS_RUNNING (BOOL) │ │ Type: INT │ │ +│ │ ├── ▶ ☐ TON0 (TON) │ │ Perms: V:r O:rw E:rw │ │ +│ │ ├── ▶ ☐ MOTOR_CTRL (FB_Mot) │ ├────────────────────────────│ │ +│ │ ├── ▶ ☐ SENSOR (T_Sensor) │ ▼ PLC.Main.MOTOR_CTRL │ │ +│ │ └── ▶ ☐ TEMPS (ARRAY[1..10])│ │ main:MOTOR_CTRL (FB) │ │ +│ │ ▼ 📁 GVL (Global Variables) │ │ [Entire FB selected] │ │ +│ │ ├── ☐ SYSTEM_STATE (INT) │ ├── ENABLE (BOOL) │ │ +│ │ └── ☐ ERROR_CODE (DINT) │ ├── SPEED_SP (INT) │ │ +│ │ ► 📁 FB_MotorControl (FB) │ └── PID_CTRL.OUTPUT (REAL) │ │ +│ │ ► 📁 FB_PID (FB) │ ─ PLC.GVL.SystemState │ │ +│ │ │ │ GVL:SYSTEM_STATE │ │ +│ │ │ │ Type: INT │ │ +│ │ │ │ │ +│ ├────────────────────────────────┼────────────────────────────────┤ │ +│ │ [Filter: ________] │ │ │ +│ │ │ [↑] [↓] [Edit] [Remove] │ │ +│ │ [Add Selected →] │ │ │ +│ └────────────────────────────────┴────────────────────────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.3 Variable Tree Structure + +The left panel shows all project variables in a tree: + +``` +▼ 📁 main (Program) + ├── MOTOR_SPEED (INT) ← Simple variable (leaf) + ├── IS_RUNNING (BOOL) ← Simple variable (leaf) + ├── ▼ TON0 (TON) ← Standard FB instance + │ ├── IN (BOOL) ← FB input + │ ├── PT (TIME) ← FB input + │ ├── Q (BOOL) ← FB output + │ └── ET (TIME) ← FB output + ├── ▼ MOTOR_CTRL (FB_MotorControl) ← Custom FB instance + │ ├── ENABLE (BOOL) ← FB variable + │ ├── SPEED_SETPOINT (INT) ← FB variable + │ ├── ACTUAL_SPEED (INT) ← FB variable + │ └── ▼ PID_CTRL (FB_PID) ← Nested FB (2 levels deep) + │ ├── KP (REAL) + │ ├── KI (REAL) + │ ├── KD (REAL) + │ └── OUTPUT (REAL) + ├── ▼ SENSOR (T_SensorData) ← Structure instance + │ ├── temperature (REAL) ← Struct field + │ ├── pressure (REAL) ← Struct field + │ └── ▼ status (T_Status) ← Nested structure + │ ├── is_valid (BOOL) + │ └── error_code (INT) + ├── ▼ TEMPS (ARRAY[1..10] OF REAL) ← Array + │ ├── [1] (REAL) + │ ├── [2] (REAL) + │ └── ... (10 elements) + └── ▼ SENSORS (ARRAY[1..5] OF T_SensorData) ← Array of structures + ├── ▼ [1] (T_SensorData) + │ ├── temperature (REAL) + │ ├── pressure (REAL) + │ └── ▼ status (T_Status) + │ └── ... + └── ... (5 elements) + +▼ 📁 GVL (Global Variables) + ├── SYSTEM_STATE (INT) + ├── ERROR_CODE (DINT) + └── ▼ CONFIG (T_SystemConfig) + └── ... + +► 📁 FB_MotorControl (Function Block Definition) + └── (Shows FB type definition - variables not directly selectable) + +► 📁 FB_PID (Function Block Definition) + └── (Shows FB type definition) +``` + +### 6.4 Selection Behavior + +Users can select: + +1. **Individual Leaves**: Select a single simple variable +2. **Entire Branches**: Select a structure, array, or FB to include all children + +``` +Selection Examples: + +☐ MOTOR_SPEED (INT) → Selects only this variable +☑ MOTOR_CTRL (FB_MotorControl) → Selects entire FB with all nested variables + ☑ ENABLE (BOOL) (auto-selected as part of parent) + ☑ SPEED_SETPOINT (INT) (auto-selected as part of parent) + ☑ PID_CTRL (FB_PID) (auto-selected as part of parent) + ☑ KP (REAL) (auto-selected, nested) + ☑ OUTPUT (REAL) (auto-selected, nested) + +Partial selection is NOT allowed - either select the parent or individual children +``` + +### 6.5 Edit Variable/Node Modal + +When editing a selected variable/node: + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Configure OPC-UA Node [X] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PLC Variable: main:MOTOR_SPEED (INT) │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ OPC-UA Node Configuration │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Node ID │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ PLC.Main.MotorSpeed │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ (Unique identifier in OPC-UA address space) │ +│ │ +│ Browse Name │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ MotorSpeed │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Display Name │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Motor Speed │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Description │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Current motor speed in RPM │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Initial Value │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ 0 │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Access Permissions │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Role Access Level │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Viewer [Read Only ▼] │ +│ Operator [Read/Write ▼] │ +│ Engineer [Read/Write ▼] │ +│ │ +│ Options: Read Only, Write Only, Read/Write │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Cancel] [Save] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.6 Edit Structure/FB Modal + +When editing a structure or function block (branch selection): + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Configure OPC-UA Structure Node [X] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PLC Variable: main:SENSOR (T_SensorData) │ +│ Type: Structure │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Structure Node Configuration │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Node ID │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ PLC.Main.Sensor │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Browse Name │ Display Name │ +│ ┌────────────────────┼────────────────────────────────────────────┐ │ +│ │ Sensor │ Sensor Data │ │ +│ └────────────────────┴────────────────────────────────────────────┘ │ +│ │ +│ Description │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Main sensor data structure │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Field Configuration │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Field │ Type │ Initial │ Viewer │ Operator │ Engr │ │ +│ ├─────────────────┼───────┼─────────┼────────┼──────────┼───────┤ │ +│ │ temperature │ REAL │ 0.0 │ r │ r │ rw │ │ +│ │ pressure │ REAL │ 0.0 │ r │ r │ rw │ │ +│ │ status.is_valid │ BOOL │ false │ r │ r │ r │ │ +│ │ status.error_co │ INT │ 0 │ r │ r │ rw │ │ +│ └─────────────────┴───────┴─────────┴────────┴──────────┴───────┘ │ +│ │ +│ [Edit Field Permissions...] │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Cancel] [Save] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.7 Edit Array Modal + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Configure OPC-UA Array Node [X] │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ PLC Variable: main:TEMPS (ARRAY[1..10] OF REAL) │ +│ Type: Array │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Array Node Configuration │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Node ID │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ PLC.Main.Temperatures │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ Browse Name │ Display Name │ +│ ┌────────────────────┼────────────────────────────────────────────┐ │ +│ │ Temperatures │ Temperature Readings │ │ +│ └────────────────────┴────────────────────────────────────────────┘ │ +│ │ +│ Description │ +│ ┌─────────────────────────────────────────────────────────────────┐ │ +│ │ Array of temperature sensor values │ │ +│ └─────────────────────────────────────────────────────────────────┘ │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ Array Properties │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ Element Type: REAL │ +│ Array Length: 10 (indices 1 to 10) │ +│ Initial Value: 0.0 (applied to all elements) │ +│ │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ Access Permissions (applies to entire array) │ +│ ═══════════════════════════════════════════════════════════════════ │ +│ │ +│ Viewer: [Read Only ▼] │ +│ Operator: [Read/Write ▼] │ +│ Engineer: [Read/Write ▼] │ +│ │ +│ ───────────────────────────────────────────────────────────────────── │ +│ │ +│ [Cancel] [Save] │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 6.8 Deep Nesting Example + +For deeply nested structures (FB inside FB, struct inside struct), the tree expands fully: + +``` +▼ 📁 main (Program) + └── ▼ PROCESS_CTRL (FB_ProcessController) + ├── ENABLE (BOOL) + ├── MODE (INT) + └── ▼ HEATER_CTRL (FB_HeaterController) + ├── SETPOINT (REAL) + ├── ACTUAL (REAL) + └── ▼ PID (FB_PID) + ├── KP (REAL) + ├── KI (REAL) + ├── KD (REAL) + ├── ERROR (REAL) + └── OUTPUT (REAL) + +When user selects PROCESS_CTRL, all nested items are included. +The variablePath for deepest item would be: + "PROCESS_CTRL.HEATER_CTRL.PID.OUTPUT" +``` + +--- + +## 7. Responsive Design Considerations + +### 7.1 Minimum Window Size + +- Minimum width: 800px +- Minimum height: 600px + +### 7.2 Panel Resizing + +The Address Space tab should support resizable panels: +- Left panel (Available Variables): min 250px, max 50% +- Right panel (Selected Variables): min 250px, remaining space + +### 7.3 Tree Virtualization + +For projects with many variables, implement virtualized tree rendering: +- Use react-window or similar for large lists +- Lazy-load children when expanding nodes +- Show loading indicator while expanding complex FBs + +--- + +## 8. Accessibility + +### 8.1 Keyboard Navigation + +- Tab: Move between panels and controls +- Arrow keys: Navigate tree structure +- Space: Toggle checkbox selection +- Enter: Expand/collapse tree node or open edit modal + +### 8.2 Screen Reader Support + +- Proper ARIA labels on all interactive elements +- Tree structure uses role="tree" and role="treeitem" +- Selection state announced: "Selected" / "Not selected" + +### 8.3 Color Contrast + +- All text meets WCAG 2.1 AA contrast requirements +- Icons have sufficient contrast or text alternatives +- Error states use both color and icon indicators diff --git a/docs/opcua-server-configuration/03-json-configuration-mapping.md b/docs/opcua-server-configuration/03-json-configuration-mapping.md new file mode 100644 index 000000000..23a3412b0 --- /dev/null +++ b/docs/opcua-server-configuration/03-json-configuration-mapping.md @@ -0,0 +1,959 @@ +# OPC-UA Server Configuration - JSON Configuration Mapping + +This document describes how the editor configuration maps to the runtime JSON format expected by the OPC-UA plugin. + +## 1. Overview + +The OPC-UA configuration undergoes transformation during compilation: + +``` +┌─────────────────────────┐ ┌─────────────────────────┐ +│ Editor Format │ │ Runtime Format │ +│ (Project File) │ ──► │ (opcua.json) │ +├─────────────────────────┤ ├─────────────────────────┤ +│ • camelCase properties │ │ • snake_case properties │ +│ • Variable references │ │ • Resolved indices │ +│ • No indices │ │ • Full variable info │ +│ • TypeScript types │ │ • JSON structure │ +└─────────────────────────┘ └─────────────────────────┘ +``` + +## 2. Editor Data Model (Stored in Project) + +### 2.1 Complete Type Definitions + +```typescript +// ═══════════════════════════════════════════════════════════════════════ +// Security Profile +// ═══════════════════════════════════════════════════════════════════════ + +interface OpcUaSecurityProfile { + id: string // UUID + name: string // Profile identifier (e.g., "insecure", "signed") + enabled: boolean + securityPolicy: 'None' | 'Basic128Rsa15' | 'Basic256' | 'Basic256Sha256' + securityMode: 'None' | 'Sign' | 'SignAndEncrypt' + authMethods: ('Anonymous' | 'Username' | 'Certificate')[] +} + +// ═══════════════════════════════════════════════════════════════════════ +// Trusted Certificate +// ═══════════════════════════════════════════════════════════════════════ + +interface OpcUaTrustedCertificate { + id: string // Certificate identifier (referenced by users) + pem: string // PEM-encoded certificate content + // Derived fields for display (parsed from PEM) + subject?: string + validFrom?: string + validTo?: string + fingerprint?: string +} + +// ═══════════════════════════════════════════════════════════════════════ +// User Account +// ═══════════════════════════════════════════════════════════════════════ + +interface OpcUaUser { + id: string // UUID + type: 'password' | 'certificate' + // For password auth + username: string | null + passwordHash: string | null // bcrypt hash (never plain text) + // For certificate auth + certificateId: string | null // References trustedCertificate.id + // Common + role: 'viewer' | 'operator' | 'engineer' +} + +// ═══════════════════════════════════════════════════════════════════════ +// Permissions +// ═══════════════════════════════════════════════════════════════════════ + +interface OpcUaPermissions { + viewer: 'r' | 'w' | 'rw' + operator: 'r' | 'w' | 'rw' + engineer: 'r' | 'w' | 'rw' +} + +// ═══════════════════════════════════════════════════════════════════════ +// Address Space Node (Variable/Structure/Array) +// ═══════════════════════════════════════════════════════════════════════ + +interface OpcUaNodeConfig { + id: string // UUID for tracking + + // PLC variable reference (resolved to index at compile time) + pouName: string // "main", "GVL", "FB_Motor" + variablePath: string // "MOTOR_SPEED", "CTRL.PID.OUTPUT" + variableType: string // IEC type + + // OPC-UA configuration + nodeId: string // OPC-UA node identifier + browseName: string + displayName: string + description: string + initialValue: boolean | number | string + permissions: OpcUaPermissions + + // Type classification + nodeType: 'variable' | 'structure' | 'array' + + // For structures: nested field configurations + fields?: OpcUaFieldConfig[] + + // For arrays: element count (derived from type) + arrayLength?: number + elementType?: string +} + +interface OpcUaFieldConfig { + fieldPath: string // "temperature", "status.is_valid" + displayName: string + initialValue: boolean | number | string + permissions: OpcUaPermissions +} + +// ═══════════════════════════════════════════════════════════════════════ +// Complete Server Configuration +// ═══════════════════════════════════════════════════════════════════════ + +interface OpcUaServerConfig { + server: { + enabled: boolean + name: string + applicationUri: string + productUri: string + bindAddress: string + port: number + endpointPath: string + } + + securityProfiles: OpcUaSecurityProfile[] + + security: { + serverCertificateStrategy: 'auto_self_signed' | 'custom' + serverCertificateCustom: string | null + serverPrivateKeyCustom: string | null + trustedClientCertificates: OpcUaTrustedCertificate[] + } + + users: OpcUaUser[] + + cycleTimeMs: number + + addressSpace: { + namespaceUri: string + nodes: OpcUaNodeConfig[] + } +} +``` + +## 3. Runtime JSON Format (Generated) + +### 3.1 Complete Structure + +The runtime expects a JSON array containing plugin configurations: + +```json +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "string", + "application_uri": "string", + "product_uri": "string", + "endpoint_url": "string", + "security_profiles": [ + { + "name": "string", + "enabled": true, + "security_policy": "string", + "security_mode": "string", + "auth_methods": ["string"] + } + ] + }, + "security": { + "server_certificate_strategy": "string", + "server_certificate_custom": "string|null", + "server_private_key_custom": "string|null", + "trusted_client_certificates": [ + { + "id": "string", + "pem": "string" + } + ] + }, + "users": [ + { + "type": "string", + "username": "string|null", + "password_hash": "string|null", + "certificate_id": "string|null", + "role": "string" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "string", + "variables": [...], + "structures": [...], + "arrays": [...] + } + } + } +] +``` + +### 3.2 Address Space Variable Format + +```json +{ + "node_id": "PLC.Main.MotorSpeed", + "browse_name": "MotorSpeed", + "display_name": "Motor Speed", + "datatype": "INT", + "initial_value": 0, + "description": "Current motor speed in RPM", + "index": 5, + "permissions": { + "viewer": "r", + "operator": "rw", + "engineer": "rw" + } +} +``` + +### 3.3 Address Space Structure Format + +```json +{ + "node_id": "PLC.Main.Sensor", + "browse_name": "Sensor", + "display_name": "Sensor Data", + "description": "Main sensor data structure", + "fields": [ + { + "name": "temperature", + "datatype": "REAL", + "initial_value": 0.0, + "index": 10, + "permissions": { + "viewer": "r", + "operator": "r", + "engineer": "rw" + } + }, + { + "name": "pressure", + "datatype": "REAL", + "initial_value": 0.0, + "index": 11, + "permissions": { + "viewer": "r", + "operator": "r", + "engineer": "rw" + } + }, + { + "name": "status.is_valid", + "datatype": "BOOL", + "initial_value": false, + "index": 12, + "permissions": { + "viewer": "r", + "operator": "r", + "engineer": "r" + } + } + ] +} +``` + +### 3.4 Address Space Array Format + +```json +{ + "node_id": "PLC.Main.Temperatures", + "browse_name": "Temperatures", + "display_name": "Temperature Readings", + "datatype": "REAL", + "length": 10, + "initial_value": 0.0, + "index": 20, + "permissions": { + "viewer": "r", + "operator": "rw", + "engineer": "rw" + } +} +``` + +## 4. Field Mapping Reference + +### 4.1 Server Settings + +| Editor Field | Runtime JSON Field | Transformation | +|--------------|-------------------|----------------| +| server.enabled | (not in JSON) | Only generate JSON if enabled | +| server.name | config.server.name | Direct copy | +| server.applicationUri | config.server.application_uri | camelCase → snake_case | +| server.productUri | config.server.product_uri | camelCase → snake_case | +| server.bindAddress | config.server.endpoint_url | Combined into URL | +| server.port | config.server.endpoint_url | Combined into URL | +| server.endpointPath | config.server.endpoint_url | Combined into URL | + +**Endpoint URL Construction:** +```typescript +const endpointUrl = `opc.tcp://${bindAddress}:${port}${endpointPath}` +// Example: opc.tcp://0.0.0.0:4840/openplc/opcua +``` + +### 4.2 Security Profiles + +| Editor Field | Runtime JSON Field | Transformation | +|--------------|-------------------|----------------| +| securityProfile.name | security_profiles[].name | Direct copy | +| securityProfile.enabled | security_profiles[].enabled | Direct copy | +| securityProfile.securityPolicy | security_profiles[].security_policy | camelCase → snake_case | +| securityProfile.securityMode | security_profiles[].security_mode | camelCase → snake_case | +| securityProfile.authMethods | security_profiles[].auth_methods | camelCase → snake_case | + +**Note:** Only enabled profiles are included in the output. + +### 4.3 Security Settings + +| Editor Field | Runtime JSON Field | Transformation | +|--------------|-------------------|----------------| +| security.serverCertificateStrategy | security.server_certificate_strategy | camelCase → snake_case | +| security.serverCertificateCustom | security.server_certificate_custom | camelCase → snake_case | +| security.serverPrivateKeyCustom | security.server_private_key_custom | camelCase → snake_case | +| security.trustedClientCertificates | security.trusted_client_certificates | Map to {id, pem} only | + +### 4.4 Users + +| Editor Field | Runtime JSON Field | Transformation | +|--------------|-------------------|----------------| +| user.type | users[].type | Direct copy | +| user.username | users[].username | Direct copy | +| user.passwordHash | users[].password_hash | camelCase → snake_case | +| user.certificateId | users[].certificate_id | camelCase → snake_case | +| user.role | users[].role | Direct copy | + +### 4.5 Address Space Variables + +| Editor Field | Runtime JSON Field | Transformation | +|--------------|-------------------|----------------| +| node.nodeId | variables[].node_id | camelCase → snake_case | +| node.browseName | variables[].browse_name | camelCase → snake_case | +| node.displayName | variables[].display_name | camelCase → snake_case | +| node.variableType | variables[].datatype | Renamed | +| node.initialValue | variables[].initial_value | camelCase → snake_case | +| node.description | variables[].description | Direct copy | +| (resolved) | variables[].index | **Resolved from debug.c** | +| node.permissions | variables[].permissions | Direct copy | + +## 5. Index Resolution Process + +### 5.1 Overview + +Variable indices are resolved during compilation by matching the editor's variable references to entries in the generated `debug.c` file. + +``` +┌─────────────────────────────────────────────────────────────────────────┐ +│ Index Resolution Flow │ +├─────────────────────────────────────────────────────────────────────────┤ +│ │ +│ Editor Config debug.c Runtime JSON │ +│ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ │ +│ │ pouName: │ │ debug_vars[]│ │ index: 5 │ │ +│ │ "main" │ Match │ = { │ Copy │ datatype: │ │ +│ │ variablePath│ ──────────► │ [5] VAR │ ──────► │ "INT" │ │ +│ │ "SPEED" │ │ ... │ │ │ │ +│ └─────────────┘ └─────────────┘ └─────────────┘ │ +│ │ +└─────────────────────────────────────────────────────────────────────────┘ +``` + +### 5.2 Path Building Rules + +Different variable types require different debug path formats: + +#### Simple Variables (Program/FB Instance) + +```typescript +// Editor: pouName="main", variablePath="MOTOR_SPEED" +// Debug path: RES0__INSTANCE0.MOTOR_SPEED +const buildSimplePath = (pouName: string, varPath: string): string => { + return `RES0__INSTANCE0.${varPath.toUpperCase()}` +} +``` + +#### Global Variables + +```typescript +// Editor: pouName="GVL", variablePath="SYSTEM_STATE" +// Debug path: CONFIG0__SYSTEM_STATE +const buildGlobalPath = (varPath: string): string => { + return `CONFIG0__${varPath.toUpperCase()}` +} +``` + +#### Structure Fields + +```typescript +// Editor: pouName="main", variablePath="SENSOR.temperature" +// Debug path: RES0__INSTANCE0.SENSOR.value.TEMPERATURE +const buildStructFieldPath = (pouName: string, varPath: string): string => { + const parts = varPath.split('.') + const structVar = parts[0] + const fieldPath = parts.slice(1).join('.value.') + return `RES0__INSTANCE0.${structVar.toUpperCase()}.value.${fieldPath.toUpperCase()}` +} +``` + +#### Nested Structure Fields + +```typescript +// Editor: pouName="main", variablePath="SENSOR.status.is_valid" +// Debug path: RES0__INSTANCE0.SENSOR.value.STATUS.value.IS_VALID +const buildNestedStructPath = (pouName: string, varPath: string): string => { + const parts = varPath.split('.') + let path = `RES0__INSTANCE0.${parts[0].toUpperCase()}` + for (let i = 1; i < parts.length; i++) { + path += `.value.${parts[i].toUpperCase()}` + } + return path +} +``` + +#### Function Block Variables + +```typescript +// Editor: pouName="main", variablePath="TON0.ET" +// Debug path: RES0__INSTANCE0.TON0.ET +const buildFBVarPath = (pouName: string, varPath: string): string => { + return `RES0__INSTANCE0.${varPath.toUpperCase()}` +} +``` + +#### Nested Function Block Variables + +```typescript +// Editor: pouName="main", variablePath="MOTOR_CTRL.PID.OUTPUT" +// Debug path: RES0__INSTANCE0.MOTOR_CTRL.PID.OUTPUT +const buildNestedFBPath = (pouName: string, varPath: string): string => { + return `RES0__INSTANCE0.${varPath.toUpperCase()}` +} +``` + +#### Array Elements + +```typescript +// Editor: pouName="main", variablePath="TEMPS" +// Debug path for element [0]: RES0__INSTANCE0.TEMPS.value.table[0] +// Index is the starting index; elements are sequential +const buildArrayPath = (pouName: string, varPath: string, elementIndex: number): string => { + return `RES0__INSTANCE0.${varPath.toUpperCase()}.value.table[${elementIndex}]` +} +``` + +### 5.3 Resolution Algorithm + +```typescript +import { parseDebugFile, DebugVariable } from '@root/renderer/utils/parse-debug-file' + +interface ResolvedVariable { + nodeId: string + browseName: string + displayName: string + datatype: string + initialValue: any + description: string + index: number + permissions: OpcUaPermissions +} + +const resolveVariableIndex = ( + node: OpcUaNodeConfig, + debugVariables: DebugVariable[] +): number => { + // Build the expected debug path based on variable location + let debugPath: string + + if (node.pouName === 'GVL' || node.pouName === 'CONFIG') { + // Global variable + debugPath = `CONFIG0__${node.variablePath.toUpperCase()}` + } else { + // Instance variable (program or FB) + // Handle nested paths by checking for struct/array patterns + debugPath = buildInstancePath(node.pouName, node.variablePath) + } + + // Find matching entry in debug.c + const match = debugVariables.find( + dv => dv.name.toUpperCase() === debugPath.toUpperCase() + ) + + if (!match) { + throw new Error( + `Cannot resolve index for variable: ${node.pouName}:${node.variablePath}\n` + + `Expected debug path: ${debugPath}` + ) + } + + return match.index +} + +const buildInstancePath = (pouName: string, variablePath: string): string => { + // For struct fields, insert ".value." before each field access + // For FB variables, use direct dot notation + // This logic should match the debugger's path building + + const parts = variablePath.split('.') + + // Check if this involves struct fields (need .value. insertion) + // This requires type information to determine correctly + // For now, use the same logic as the debugger + + return `RES0__INSTANCE0.${variablePath.toUpperCase()}` +} +``` + +### 5.4 Structure Index Resolution + +For structures, each field gets its own index: + +```typescript +const resolveStructureIndices = ( + node: OpcUaNodeConfig, + debugVariables: DebugVariable[] +): RuntimeStructure => { + const fields = node.fields!.map(field => { + const fieldPath = `${node.variablePath}.${field.fieldPath}` + const debugPath = buildStructFieldDebugPath(node.pouName, fieldPath) + + const match = debugVariables.find( + dv => dv.name.toUpperCase() === debugPath.toUpperCase() + ) + + if (!match) { + throw new Error(`Cannot resolve index for field: ${fieldPath}`) + } + + return { + name: field.fieldPath, + datatype: getFieldType(node.variableType, field.fieldPath), + initial_value: field.initialValue, + index: match.index, + permissions: field.permissions, + } + }) + + return { + node_id: node.nodeId, + browse_name: node.browseName, + display_name: node.displayName, + description: node.description, + fields, + } +} +``` + +### 5.5 Array Index Resolution + +For arrays, only the starting index is stored; elements are sequential: + +```typescript +const resolveArrayIndex = ( + node: OpcUaNodeConfig, + debugVariables: DebugVariable[] +): RuntimeArray => { + // Find the first element's index + const firstElementPath = buildArrayElementPath( + node.pouName, + node.variablePath, + 0 // First element (C-style index) + ) + + const match = debugVariables.find( + dv => dv.name.toUpperCase() === firstElementPath.toUpperCase() + ) + + if (!match) { + throw new Error(`Cannot resolve index for array: ${node.variablePath}`) + } + + return { + node_id: node.nodeId, + browse_name: node.browseName, + display_name: node.displayName, + datatype: node.elementType!, + length: node.arrayLength!, + initial_value: node.initialValue, + index: match.index, // Starting index + permissions: node.permissions, + } +} +``` + +## 6. Complete Generator Implementation + +```typescript +// src/utils/opcua/generate-opcua-config.ts + +import { parseDebugFile, DebugVariable } from '@root/renderer/utils/parse-debug-file' +import type { PLCServer, OpcUaServerConfig, OpcUaNodeConfig } from '@root/types/PLC/open-plc' + +interface RuntimeConfig { + name: string + protocol: 'OPC-UA' + config: { + server: RuntimeServerConfig + security: RuntimeSecurityConfig + users: RuntimeUser[] + cycle_time_ms: number + address_space: RuntimeAddressSpace + } +} + +export const generateOpcUaConfig = ( + servers: PLCServer[] | undefined, + debugFileContent: string +): string | null => { + // 1. Find OPC-UA server configuration + if (!servers || servers.length === 0) return null + + const opcuaServer = servers.find( + s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled + ) + + if (!opcuaServer?.opcuaServerConfig) return null + + const config = opcuaServer.opcuaServerConfig + + // 2. Parse debug.c to get variable indices + const parsed = parseDebugFile(debugFileContent) + const debugVariables = parsed.variables + + // 3. Build runtime configuration + const runtimeConfig: RuntimeConfig = { + name: 'opcua_server', + protocol: 'OPC-UA', + config: { + server: buildServerConfig(config), + security: buildSecurityConfig(config), + users: buildUsersConfig(config), + cycle_time_ms: config.cycleTimeMs, + address_space: buildAddressSpace(config, debugVariables), + }, + } + + // 4. Return as JSON string + return JSON.stringify([runtimeConfig], null, 2) +} + +const buildServerConfig = (config: OpcUaServerConfig): RuntimeServerConfig => { + const { server, securityProfiles } = config + + return { + name: server.name, + application_uri: server.applicationUri, + product_uri: server.productUri, + endpoint_url: `opc.tcp://${server.bindAddress}:${server.port}${server.endpointPath}`, + security_profiles: securityProfiles + .filter(sp => sp.enabled) + .map(sp => ({ + name: sp.name, + enabled: sp.enabled, + security_policy: sp.securityPolicy, + security_mode: sp.securityMode, + auth_methods: sp.authMethods, + })), + } +} + +const buildSecurityConfig = (config: OpcUaServerConfig): RuntimeSecurityConfig => { + const { security } = config + + return { + server_certificate_strategy: security.serverCertificateStrategy, + server_certificate_custom: security.serverCertificateCustom, + server_private_key_custom: security.serverPrivateKeyCustom, + trusted_client_certificates: security.trustedClientCertificates.map(cert => ({ + id: cert.id, + pem: cert.pem, + })), + } +} + +const buildUsersConfig = (config: OpcUaServerConfig): RuntimeUser[] => { + return config.users.map(user => ({ + type: user.type, + username: user.username, + password_hash: user.passwordHash, + certificate_id: user.certificateId, + role: user.role, + })) +} + +const buildAddressSpace = ( + config: OpcUaServerConfig, + debugVariables: DebugVariable[] +): RuntimeAddressSpace => { + const variables: RuntimeVariable[] = [] + const structures: RuntimeStructure[] = [] + const arrays: RuntimeArray[] = [] + + for (const node of config.addressSpace.nodes) { + switch (node.nodeType) { + case 'variable': + variables.push(resolveVariable(node, debugVariables)) + break + case 'structure': + structures.push(resolveStructure(node, debugVariables)) + break + case 'array': + arrays.push(resolveArray(node, debugVariables)) + break + } + } + + return { + namespace_uri: config.addressSpace.namespaceUri, + variables, + structures, + arrays, + } +} + +// ... resolution functions as defined in section 5 +``` + +## 7. Example Transformation + +### 7.1 Editor Configuration (Stored in Project) + +```json +{ + "server": { + "enabled": true, + "name": "OpenPLC OPC UA Server", + "applicationUri": "urn:openplc:opcua:server", + "productUri": "urn:openplc:runtime", + "bindAddress": "0.0.0.0", + "port": 4840, + "endpointPath": "/openplc/opcua" + }, + "securityProfiles": [ + { + "id": "uuid-1", + "name": "insecure", + "enabled": true, + "securityPolicy": "None", + "securityMode": "None", + "authMethods": ["Anonymous"] + } + ], + "security": { + "serverCertificateStrategy": "auto_self_signed", + "serverCertificateCustom": null, + "serverPrivateKeyCustom": null, + "trustedClientCertificates": [] + }, + "users": [ + { + "id": "uuid-2", + "type": "password", + "username": "engineer", + "passwordHash": "$2b$12$...", + "certificateId": null, + "role": "engineer" + } + ], + "cycleTimeMs": 100, + "addressSpace": { + "namespaceUri": "urn:openplc:opcua:namespace", + "nodes": [ + { + "id": "uuid-3", + "pouName": "main", + "variablePath": "MOTOR_SPEED", + "variableType": "INT", + "nodeId": "PLC.Main.MotorSpeed", + "browseName": "MotorSpeed", + "displayName": "Motor Speed", + "description": "Current motor speed", + "initialValue": 0, + "permissions": { + "viewer": "r", + "operator": "rw", + "engineer": "rw" + }, + "nodeType": "variable" + } + ] + } +} +``` + +### 7.2 Generated Runtime JSON (opcua.json) + +```json +[ + { + "name": "opcua_server", + "protocol": "OPC-UA", + "config": { + "server": { + "name": "OpenPLC OPC UA Server", + "application_uri": "urn:openplc:opcua:server", + "product_uri": "urn:openplc:runtime", + "endpoint_url": "opc.tcp://0.0.0.0:4840/openplc/opcua", + "security_profiles": [ + { + "name": "insecure", + "enabled": true, + "security_policy": "None", + "security_mode": "None", + "auth_methods": ["Anonymous"] + } + ] + }, + "security": { + "server_certificate_strategy": "auto_self_signed", + "server_certificate_custom": null, + "server_private_key_custom": null, + "trusted_client_certificates": [] + }, + "users": [ + { + "type": "password", + "username": "engineer", + "password_hash": "$2b$12$...", + "certificate_id": null, + "role": "engineer" + } + ], + "cycle_time_ms": 100, + "address_space": { + "namespace_uri": "urn:openplc:opcua:namespace", + "variables": [ + { + "node_id": "PLC.Main.MotorSpeed", + "browse_name": "MotorSpeed", + "display_name": "Motor Speed", + "datatype": "INT", + "initial_value": 0, + "description": "Current motor speed", + "index": 5, + "permissions": { + "viewer": "r", + "operator": "rw", + "engineer": "rw" + } + } + ], + "structures": [], + "arrays": [] + } + } + } +] +``` + +## 8. Error Handling + +### 8.1 Index Resolution Errors + +When a variable cannot be resolved: + +```typescript +class OpcUaConfigError extends Error { + constructor( + public readonly variableRef: string, + public readonly expectedPath: string, + public readonly message: string + ) { + super(message) + this.name = 'OpcUaConfigError' + } +} + +// Usage: +if (!match) { + throw new OpcUaConfigError( + `${node.pouName}:${node.variablePath}`, + debugPath, + `Cannot resolve OPC-UA variable index.\n` + + `Variable: ${node.pouName}:${node.variablePath}\n` + + `Expected debug path: ${debugPath}\n` + + `This may happen if the PLC program was modified after configuring OPC-UA.\n` + + `Please verify the variable exists in the program.` + ) +} +``` + +### 8.2 Validation Before Generation + +```typescript +const validateAddressSpace = ( + nodes: OpcUaNodeConfig[], + debugVariables: DebugVariable[] +): { valid: boolean; errors: string[] } => { + const errors: string[] = [] + + for (const node of nodes) { + try { + resolveVariableIndex(node, debugVariables) + } catch (e) { + errors.push((e as Error).message) + } + } + + return { + valid: errors.length === 0, + errors, + } +} +``` + +## 9. Integration with Compiler + +The OPC-UA JSON generation is called as part of the compilation process: + +```typescript +// In compiler-module.ts + +async handleGenerateOpcUaConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData +): Promise { + // Check if OPC-UA is enabled + const opcuaServer = projectData.servers?.find( + s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled + ) + + if (!opcuaServer) { + // OPC-UA not configured, skip + return + } + + // Read the debug.c file generated by xml2st + const debugCPath = join(sourceTargetFolderPath, 'debug.c') + const debugContent = await readFile(debugCPath, 'utf-8') + + // Generate the OPC-UA configuration + const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent) + + if (opcuaJson) { + // Write to conf/opcua.json + const confDir = join(sourceTargetFolderPath, 'conf') + await mkdir(confDir, { recursive: true }) + await writeFile(join(confDir, 'opcua.json'), opcuaJson, 'utf-8') + } +} +``` diff --git a/docs/opcua-server-configuration/04-implementation-phases.md b/docs/opcua-server-configuration/04-implementation-phases.md new file mode 100644 index 000000000..192e21c4d --- /dev/null +++ b/docs/opcua-server-configuration/04-implementation-phases.md @@ -0,0 +1,973 @@ +# OPC-UA Server Configuration - Implementation Phases + +This document outlines a phased approach to implementing the OPC-UA server configuration feature in OpenPLC Editor. + +## Overview + +The implementation is divided into 6 phases, designed to deliver incremental functionality while managing complexity: + +| Phase | Focus | Deliverable | +|-------|-------|-------------| +| 1 | Foundation & Types | Type definitions, project integration, basic UI shell | +| 2 | General Settings & Security Profiles | First two tabs fully functional | +| 3 | Users & Certificates | Authentication and certificate management | +| 4 | Address Space - Basic Variables | Variable tree and simple variable selection | +| 5 | Address Space - Complex Types | Structures, arrays, nested FBs | +| 6 | Compiler Integration & Testing | JSON generation, end-to-end testing | + +--- + +## Phase 1: Foundation & Type Definitions + +### Objective +Establish the foundational type system and basic UI infrastructure for OPC-UA configuration. + +### Deliverables + +#### 1.1 Type Definitions + +**File:** `src/types/PLC/open-plc.ts` + +Add Zod schemas for all OPC-UA configuration types: + +```typescript +// Security Profile Schema +const OpcUaSecurityProfileSchema = z.object({ + id: z.string().uuid(), + name: z.string().min(1).max(64), + enabled: z.boolean(), + securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']), + securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']), + authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])).min(1), +}) + +// Trusted Certificate Schema +const OpcUaTrustedCertificateSchema = z.object({ + id: z.string().min(1).max(64), + pem: z.string(), + subject: z.string().optional(), + validFrom: z.string().optional(), + validTo: z.string().optional(), + fingerprint: z.string().optional(), +}) + +// User Schema +const OpcUaUserSchema = z.object({ + id: z.string().uuid(), + type: z.enum(['password', 'certificate']), + username: z.string().nullable(), + passwordHash: z.string().nullable(), + certificateId: z.string().nullable(), + role: z.enum(['viewer', 'operator', 'engineer']), +}) + +// Permissions Schema +const OpcUaPermissionsSchema = z.object({ + viewer: z.enum(['r', 'w', 'rw']).default('r'), + operator: z.enum(['r', 'w', 'rw']).default('r'), + engineer: z.enum(['r', 'w', 'rw']).default('rw'), +}) + +// Field Configuration Schema (for structures) +const OpcUaFieldConfigSchema = z.object({ + fieldPath: z.string(), + displayName: z.string(), + initialValue: z.union([z.boolean(), z.number(), z.string()]), + permissions: OpcUaPermissionsSchema, +}) + +// Node Configuration Schema +const OpcUaNodeConfigSchema = z.object({ + id: z.string().uuid(), + pouName: z.string(), + variablePath: z.string(), + variableType: z.string(), + nodeId: z.string(), + browseName: z.string(), + displayName: z.string(), + description: z.string().default(''), + initialValue: z.union([z.boolean(), z.number(), z.string()]), + permissions: OpcUaPermissionsSchema, + nodeType: z.enum(['variable', 'structure', 'array']), + fields: z.array(OpcUaFieldConfigSchema).optional(), + arrayLength: z.number().optional(), + elementType: z.string().optional(), +}) + +// Complete Server Configuration Schema +const OpcUaServerConfigSchema = z.object({ + server: z.object({ + enabled: z.boolean().default(false), + name: z.string().max(128).default('OpenPLC OPC UA Server'), + applicationUri: z.string().default('urn:openplc:opcua:server'), + productUri: z.string().default('urn:openplc:runtime'), + bindAddress: z.string().default('0.0.0.0'), + port: z.number().int().min(1).max(65535).default(4840), + endpointPath: z.string().default('/openplc/opcua'), + }), + securityProfiles: z.array(OpcUaSecurityProfileSchema).default([]), + security: z.object({ + serverCertificateStrategy: z.enum(['auto_self_signed', 'custom']).default('auto_self_signed'), + serverCertificateCustom: z.string().nullable().default(null), + serverPrivateKeyCustom: z.string().nullable().default(null), + trustedClientCertificates: z.array(OpcUaTrustedCertificateSchema).default([]), + }), + users: z.array(OpcUaUserSchema).default([]), + cycleTimeMs: z.number().int().min(10).max(10000).default(100), + addressSpace: z.object({ + namespaceUri: z.string().default('urn:openplc:opcua:namespace'), + nodes: z.array(OpcUaNodeConfigSchema).default([]), + }), +}) +``` + +#### 1.2 Protocol Extension + +**File:** `src/types/PLC/open-plc.ts` + +Add 'opcua' to the PLCServer protocol union: + +```typescript +const PLCServerSchema = z.object({ + name: z.string(), + protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua']), + modbusSlaveConfig: ModbusSlaveConfigSchema.optional(), + s7commSlaveConfig: S7CommSlaveConfigSchema.optional(), + opcuaServerConfig: OpcUaServerConfigSchema.optional(), // Add this +}) +``` + +#### 1.3 Project Slice Actions + +**File:** `src/renderer/store/slices/project/slice.ts` + +Add basic CRUD actions for OPC-UA configuration: + +```typescript +// OPC-UA Server Actions +createOpcUaServer: (serverName: string) => void +updateOpcUaServerSettings: (serverName: string, settings: Partial) => void +deleteOpcUaServer: (serverName: string) => void +``` + +#### 1.4 UI Shell Component + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx` + +Create the basic tabbed UI structure: + +```tsx +const OpcUaServerEditor = () => { + const [activeTab, setActiveTab] = useState('general') + + const tabs = [ + { id: 'general', label: 'General Settings' }, + { id: 'security', label: 'Security Profiles' }, + { id: 'users', label: 'Users' }, + { id: 'certificates', label: 'Certificates' }, + { id: 'address-space', label: 'Address Space' }, + ] + + return ( +
+ +
+ {activeTab === 'general' && } + {activeTab === 'security' && } + {activeTab === 'users' && } + {activeTab === 'certificates' && } + {activeTab === 'address-space' && } +
+
+ ) +} +``` + +### Acceptance Criteria + +- [ ] All Zod schemas defined and exported +- [ ] PLCServer type updated with opcuaServerConfig +- [ ] Basic project slice actions implemented +- [ ] Tabbed UI component renders with placeholder content +- [ ] OPC-UA server can be created/deleted from Servers panel + +--- + +## Phase 2: General Settings & Security Profiles + +### Objective +Implement the first two configuration tabs with full functionality. + +### Deliverables + +#### 2.1 General Settings Tab + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/general-settings.tsx` + +Implement all fields: +- Enable toggle +- Server name +- Application URI +- Product URI +- Bind address (dropdown with common options) +- Port number +- Endpoint path +- Cycle time with validation + +```tsx +const GeneralSettingsTab = () => { + const { project, projectActions } = useOpenPLCStore() + const opcuaConfig = getOpcUaConfig(project) + + const handleUpdateServer = (updates: Partial) => { + projectActions.updateOpcUaServerSettings(serverName, updates) + } + + return ( +
+ {/* Enable Toggle */} +
+ + handleUpdateServer({ enabled })} + /> +
+ + {/* Server Identity Section */} +
+

Server Identity

+ {/* Form fields */} +
+ + {/* Network Configuration Section */} +
+

Network Configuration

+ {/* Form fields with endpoint URL preview */} +
+ + {/* Performance Section */} +
+

Performance

+ {/* Cycle time input */} +
+
+ ) +} +``` + +#### 2.2 Security Profiles Tab + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/security-profiles.tsx` + +Implement: +- List of security profiles with enable/disable toggle +- Add/Edit/Delete profile functionality +- Profile modal with all settings +- Validation rules (Anonymous only with None policy, etc.) + +#### 2.3 Security Profile Modal + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx` + +Implement modal with: +- Profile name input +- Enable toggle +- Security policy dropdown +- Security mode dropdown +- Authentication methods checkboxes +- Validation feedback + +#### 2.4 Project Slice Actions + +Add actions for security profile management: + +```typescript +addOpcUaSecurityProfile: (serverName: string, profile: OpcUaSecurityProfile) => void +updateOpcUaSecurityProfile: (serverName: string, profileId: string, updates: Partial) => void +removeOpcUaSecurityProfile: (serverName: string, profileId: string) => void +``` + +### Acceptance Criteria + +- [ ] General settings tab saves all fields correctly +- [ ] Endpoint URL preview updates dynamically +- [ ] Cycle time validates range (10-10000) +- [ ] Security profiles can be added/edited/removed +- [ ] At least one profile must be enabled (validation) +- [ ] Anonymous auth only available with None policy +- [ ] Profile list shows summary of each profile + +--- + +## Phase 3: Users & Certificates + +### Objective +Implement user authentication and certificate management. + +### Deliverables + +#### 3.1 Users Tab + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/users.tsx` + +Implement: +- User list with role badges +- Add/Edit/Delete user functionality +- User type toggle (password/certificate) +- Password validation and hashing + +#### 3.2 User Modal + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx` + +Implement: +- Authentication type selector +- Password fields with strength indicator +- Certificate selector (from trusted certificates) +- Role selector with descriptions + +#### 3.3 Password Hashing Utility + +**File:** `src/utils/opcua/bcrypt-utils.ts` + +```typescript +import bcrypt from 'bcryptjs' + +export const hashPassword = async (password: string): Promise => { + const salt = await bcrypt.genSalt(12) + return bcrypt.hash(password, salt) +} + +export const verifyPassword = async ( + password: string, + hash: string +): Promise => { + return bcrypt.compare(password, hash) +} +``` + +#### 3.4 Certificates Tab + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/certificates.tsx` + +Implement: +- Server certificate strategy selector +- Custom certificate upload (when strategy = custom) +- Private key upload +- Trusted client certificates list +- Certificate details display + +#### 3.5 Certificate Parsing Utility + +**File:** `src/utils/opcua/certificate-utils.ts` + +Use a library like `node-forge` to parse PEM certificates: + +```typescript +import forge from 'node-forge' + +export interface CertificateInfo { + subject: string + issuer: string + validFrom: Date + validTo: Date + fingerprint: string +} + +export const parsePemCertificate = (pem: string): CertificateInfo => { + const cert = forge.pki.certificateFromPem(pem) + return { + subject: cert.subject.getField('CN')?.value || 'Unknown', + issuer: cert.issuer.getField('CN')?.value || 'Unknown', + validFrom: cert.validity.notBefore, + validTo: cert.validity.notAfter, + fingerprint: forge.md.sha256 + .create() + .update(forge.asn1.toDer(forge.pki.certificateToAsn1(cert)).getBytes()) + .digest() + .toHex(), + } +} +``` + +#### 3.6 Add Trusted Certificate Modal + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx` + +Implement: +- Certificate ID input +- PEM content text area +- File browse button +- Certificate details preview (parsed from PEM) + +### Acceptance Criteria + +- [ ] Password users can be created with bcrypt hashing +- [ ] Certificate users can be created referencing trusted certs +- [ ] At least one user required when Username auth enabled +- [ ] Server certificate strategy works (auto/custom) +- [ ] Custom certificates can be uploaded via file or paste +- [ ] Trusted certificates show parsed details (subject, validity) +- [ ] Certificate IDs are unique + +--- + +## Phase 4: Address Space - Basic Variables + +### Objective +Implement the variable tree and basic variable selection functionality. + +### Deliverables + +#### 4.1 Address Space Tab + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/tabs/address-space.tsx` + +Implement: +- Namespace URI input +- Split-pane layout (available variables | selected variables) +- Add/Remove buttons +- Filter input for variable search + +#### 4.2 Variable Tree Component + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-tree.tsx` + +Build the hierarchical variable tree from project POUs: + +```tsx +interface TreeNode { + id: string + name: string + type: 'program' | 'function_block' | 'global' | 'variable' | 'structure' | 'array' + variableType?: string // IEC type for variables + children?: TreeNode[] + pouName: string + variablePath: string + isSelectable: boolean + isExpanded?: boolean +} + +const VariableTree = ({ + nodes, + selectedIds, + onSelect, + onExpand, +}: VariableTreeProps) => { + const renderNode = (node: TreeNode, depth: number) => ( +
+
+ {node.children && ( + onExpand(node.id)} + /> + )} + {node.isSelectable && ( + onSelect(node)} + /> + )} + + {node.name} + {node.variableType && ( + ({node.variableType}) + )} +
+ {node.isExpanded && node.children?.map(child => + renderNode(child, depth + 1) + )} +
+ ) + + return
{nodes.map(node => renderNode(node, 0))}
+} +``` + +#### 4.3 Variable Extraction from Project + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/hooks/use-project-variables.ts` + +Extract variables from project POUs: + +```typescript +const useProjectVariables = (): TreeNode[] => { + const { project } = useOpenPLCStore() + + return useMemo(() => { + const nodes: TreeNode[] = [] + + // Programs and Function Block instances + for (const pou of project.data.pous) { + if (pou.type === 'program') { + nodes.push(buildProgramNode(pou, project.data)) + } + } + + // Global Variables + if (project.data.globalVariables?.length) { + nodes.push(buildGlobalVariablesNode(project.data.globalVariables)) + } + + return nodes + }, [project.data]) +} + +const buildProgramNode = (pou: POU, projectData: PLCProjectData): TreeNode => { + return { + id: `pou-${pou.data.name}`, + name: pou.data.name, + type: 'program', + pouName: pou.data.name, + variablePath: '', + isSelectable: false, + children: pou.data.variables.map(v => + buildVariableNode(v, pou.data.name, projectData) + ), + } +} +``` + +#### 4.4 Simple Variable Configuration Modal + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/variable-config-modal.tsx` + +Implement modal for configuring selected variables: +- Node ID (auto-generated, editable) +- Browse name +- Display name +- Description +- Initial value (type-appropriate input) +- Permissions matrix + +#### 4.5 Selected Variables List + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/selected-variables-list.tsx` + +Display selected variables with: +- Node summary (node ID, type, permissions) +- Edit/Remove buttons +- Drag-and-drop reordering + +### Acceptance Criteria + +- [ ] Variable tree displays all program variables +- [ ] Tree supports expand/collapse for nested items +- [ ] Simple variables (INT, BOOL, REAL, etc.) can be selected +- [ ] Selected variables appear in right panel +- [ ] Variables can be configured with OPC-UA properties +- [ ] Node IDs are auto-generated but editable +- [ ] Permissions can be set per variable +- [ ] Variables can be removed from selection + +--- + +## Phase 5: Address Space - Complex Types + +### Objective +Add support for structures, arrays, and deeply nested function blocks. + +### Deliverables + +#### 5.1 Structure Support + +Extend the variable tree to show structure fields: + +```typescript +const buildStructureNode = ( + variable: Variable, + pouName: string, + structType: StructType +): TreeNode => { + return { + id: `${pouName}-${variable.name}`, + name: variable.name, + type: 'structure', + variableType: variable.type.value, + pouName, + variablePath: variable.name, + isSelectable: true, // Can select entire structure + children: structType.variable.map(field => ({ + id: `${pouName}-${variable.name}-${field.name}`, + name: field.name, + type: 'variable', + variableType: field.type.value, + pouName, + variablePath: `${variable.name}.${field.name}`, + isSelectable: true, // Or select individual fields + })), + } +} +``` + +#### 5.2 Structure Configuration Modal + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/structure-config-modal.tsx` + +Implement modal for structure configuration: +- Structure-level properties (node ID, browse name, etc.) +- Field table with individual permissions +- Nested structure handling + +#### 5.3 Array Support + +Extend the variable tree for arrays: + +```typescript +const buildArrayNode = ( + variable: Variable, + pouName: string, + arrayDimension: string // e.g., "1..10" +): TreeNode => { + const [min, max] = arrayDimension.split('..').map(Number) + const length = max - min + 1 + + return { + id: `${pouName}-${variable.name}`, + name: variable.name, + type: 'array', + variableType: `ARRAY[${arrayDimension}] OF ${elementType}`, + pouName, + variablePath: variable.name, + isSelectable: true, + children: Array.from({ length }, (_, i) => ({ + id: `${pouName}-${variable.name}-${min + i}`, + name: `[${min + i}]`, + type: 'variable', + variableType: elementType, + pouName, + variablePath: `${variable.name}[${min + i}]`, + isSelectable: true, + })), + } +} +``` + +#### 5.4 Array Configuration Modal + +**File:** `src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/array-config-modal.tsx` + +Implement modal for array configuration: +- Array-level properties +- Element type display +- Length display +- Permissions (apply to all elements) + +#### 5.5 Nested Function Block Support + +Handle deeply nested FB instances: + +```typescript +const buildFunctionBlockNode = ( + variable: Variable, + pouName: string, + fbType: FunctionBlock, + projectData: PLCProjectData, + currentPath: string = '' +): TreeNode => { + const variablePath = currentPath + ? `${currentPath}.${variable.name}` + : variable.name + + return { + id: `${pouName}-${variablePath}`, + name: variable.name, + type: 'function_block', + variableType: variable.type.value, + pouName, + variablePath, + isSelectable: true, // Select entire FB + children: fbType.variable.map(fbVar => { + // Check if this FB variable is itself an FB + const nestedFbType = findFunctionBlock(fbVar.type.value, projectData) + + if (nestedFbType) { + // Recursive: nested FB + return buildFunctionBlockNode( + fbVar, + pouName, + nestedFbType, + projectData, + variablePath + ) + } + + // Leaf: simple variable + return { + id: `${pouName}-${variablePath}-${fbVar.name}`, + name: fbVar.name, + type: 'variable', + variableType: fbVar.type.value, + pouName, + variablePath: `${variablePath}.${fbVar.name}`, + isSelectable: true, + } + }), + } +} +``` + +#### 5.6 Selection Behavior for Complex Types + +Implement selection logic: + +```typescript +const handleSelectNode = (node: TreeNode) => { + if (node.type === 'variable') { + // Simple toggle + toggleSelection(node.id) + } else { + // Complex type: select/deselect all children + const childIds = getAllChildIds(node) + if (allSelected(childIds)) { + deselectAll(childIds) + } else { + selectAll(childIds) + } + } +} +``` + +#### 5.7 Deeply Nested Path Display + +Show full path in selected variables list: + +``` +main:PROCESS_CTRL.HEATER.PID.OUTPUT + └── FB ──┘ └─ FB ─┘ └─ var +``` + +### Acceptance Criteria + +- [ ] Structures display with expandable fields +- [ ] Entire structure or individual fields can be selected +- [ ] Arrays display with expandable elements +- [ ] Entire array can be selected (not individual elements for now) +- [ ] Nested FBs display correctly (FB inside FB inside FB) +- [ ] All levels of nesting are navigable +- [ ] Selection of parent auto-selects all children +- [ ] Path display shows full hierarchy + +--- + +## Phase 6: Compiler Integration & Testing + +### Objective +Integrate OPC-UA JSON generation into the compiler and perform end-to-end testing. + +### Deliverables + +#### 6.1 JSON Generator Implementation + +**File:** `src/utils/opcua/generate-opcua-config.ts` + +Implement the complete generator as documented in `03-json-configuration-mapping.md`: + +```typescript +export const generateOpcUaConfig = ( + servers: PLCServer[] | undefined, + debugFileContent: string +): string | null => { + // Implementation as documented +} +``` + +#### 6.2 Index Resolution Implementation + +**File:** `src/utils/opcua/resolve-indices.ts` + +Implement index resolution logic: + +```typescript +export const resolveVariableIndex = ( + pouName: string, + variablePath: string, + debugVariables: DebugVariable[] +): number => { + // Build debug path based on variable type + // Match against parsed debug.c + // Return index or throw error +} +``` + +#### 6.3 Compiler Module Integration + +**File:** `src/main/modules/compiler/compiler-module.ts` + +Add OPC-UA configuration generation to the compilation pipeline: + +```typescript +import { generateOpcUaConfig } from '@root/utils/opcua' + +// In the compile method, after debug.c is generated: +async handleGenerateOpcUaConfig( + sourceTargetFolderPath: string, + projectData: PLCProjectData +): Promise { + const opcuaServer = projectData.servers?.find( + s => s.protocol === 'opcua' && s.opcuaServerConfig?.server.enabled + ) + + if (!opcuaServer) return + + const debugCPath = join(sourceTargetFolderPath, 'debug.c') + const debugContent = await readFile(debugCPath, 'utf-8') + + const opcuaJson = generateOpcUaConfig(projectData.servers, debugContent) + + if (opcuaJson) { + const confDir = join(sourceTargetFolderPath, 'conf') + await mkdir(confDir, { recursive: true }) + await writeFile(join(confDir, 'opcua.json'), opcuaJson, 'utf-8') + } +} +``` + +#### 6.4 Error Handling & User Feedback + +Implement error handling for common issues: + +- Variable not found in debug.c +- Invalid configuration (no enabled profiles, no users, etc.) +- Circular references in FB structures + +```typescript +export class OpcUaConfigError extends Error { + constructor( + public code: 'VARIABLE_NOT_FOUND' | 'INVALID_CONFIG' | 'CIRCULAR_REF', + public details: Record, + message: string + ) { + super(message) + } +} +``` + +#### 6.5 Unit Tests + +**File:** `src/utils/opcua/__tests__/generate-opcua-config.test.ts` + +Write comprehensive tests: + +```typescript +describe('generateOpcUaConfig', () => { + it('returns null when OPC-UA not configured', () => { + // Test + }) + + it('generates valid JSON for simple variables', () => { + // Test + }) + + it('resolves structure field indices correctly', () => { + // Test + }) + + it('resolves array start indices correctly', () => { + // Test + }) + + it('handles nested FB paths correctly', () => { + // Test + }) + + it('throws error for unresolvable variables', () => { + // Test + }) +}) +``` + +#### 6.6 Integration Tests + +**File:** `e2e/opcua-configuration.spec.ts` + +Write end-to-end tests: + +```typescript +test('OPC-UA server can be configured and compiled', async ({ page }) => { + // 1. Create new project + // 2. Add program with variables + // 3. Configure OPC-UA server + // 4. Select variables for address space + // 5. Compile project + // 6. Verify opcua.json is generated correctly +}) +``` + +#### 6.7 Documentation Updates + +Update user documentation: +- How to configure OPC-UA server +- Security best practices +- Troubleshooting guide + +### Acceptance Criteria + +- [ ] JSON generator produces valid output +- [ ] Index resolution works for all variable types +- [ ] Compiler generates opcua.json when OPC-UA enabled +- [ ] Errors provide helpful messages +- [ ] Unit tests pass with >80% coverage +- [ ] E2E tests pass +- [ ] Generated JSON validated against runtime plugin + +--- + +## Summary Timeline + +| Phase | Description | Dependencies | +|-------|-------------|--------------| +| 1 | Foundation & Types | None | +| 2 | General Settings & Security | Phase 1 | +| 3 | Users & Certificates | Phase 2 | +| 4 | Address Space - Basic | Phase 1 | +| 5 | Address Space - Complex | Phase 4 | +| 6 | Compiler Integration | Phases 1-5 | + +**Parallel Work Opportunities:** +- Phases 2-3 (Security/Users) can be developed in parallel with Phases 4-5 (Address Space) +- Unit tests can be written alongside each phase + +--- + +## Risk Mitigation + +### Technical Risks + +| Risk | Mitigation | +|------|------------| +| Complex nested FB paths | Reuse debugger's proven path building logic | +| Password security | Use bcryptjs, never store plain text | +| Certificate parsing errors | Validate PEM format before storing | +| Large variable trees | Implement tree virtualization | + +### Integration Risks + +| Risk | Mitigation | +|------|------------| +| Index resolution failures | Comprehensive error messages with expected path | +| Runtime incompatibility | Test against actual OPC-UA plugin during development | +| Project file migration | Provide default values for new fields | + +--- + +## Dependencies + +### External Libraries + +| Library | Purpose | Phase | +|---------|---------|-------| +| bcryptjs | Password hashing | 3 | +| node-forge | Certificate parsing | 3 | +| react-window | Tree virtualization | 4 | +| uuid | Generate unique IDs | 1 | + +### Internal Dependencies + +| Component | Purpose | Phase | +|-----------|---------|-------| +| parseDebugFile | Index resolution | 6 | +| Project store | State management | 1 | +| Compiler module | JSON generation | 6 | diff --git a/docs/opcua-server-configuration/README.md b/docs/opcua-server-configuration/README.md new file mode 100644 index 000000000..b6902db6c --- /dev/null +++ b/docs/opcua-server-configuration/README.md @@ -0,0 +1,39 @@ +# OPC-UA Server Configuration + +This folder contains design documentation for the OPC-UA server configuration feature in OpenPLC Editor. + +## Overview + +The OPC-UA server configuration allows users to configure an OPC-UA server that runs on the OpenPLC Runtime. This feature enables industrial clients to access PLC variables through the OPC-UA protocol, which is significantly more complex than the existing Modbus and S7Comm protocols. + +## Key Features + +- **Full Variable Access**: Unlike Modbus/S7Comm which only access I/O image tables, OPC-UA can expose any PLC program variable +- **Security Profiles**: Multiple security configurations (None, Sign, SignAndEncrypt) +- **Certificate Management**: Server and client certificate handling +- **User Authentication**: Password-based and certificate-based authentication +- **Role-Based Access Control**: Viewer, Operator, and Engineer roles with per-variable permissions +- **Complex Data Types**: Support for structures, arrays, and nested function blocks + +## Documentation Structure + +1. **[Design Overview](./01-design-overview.md)** - High-level architecture, configuration flow, and integration points +2. **[UI Screen Specifications](./02-ui-screen-specifications.md)** - Detailed UI designs for each configuration tab +3. **[JSON Configuration Mapping](./03-json-configuration-mapping.md)** - How editor configuration maps to runtime JSON format +4. **[Implementation Phases](./04-implementation-phases.md)** - Phased implementation plan with deliverables + +## Related Documentation + +- OpenPLC Runtime OPC-UA Plugin: See the `RTOP-100-OPC-UA` branch of openplc-runtime +- Existing protocol implementations: Modbus and S7Comm configurations in openplc-editor + +## Key Differences from Modbus/S7Comm + +| Aspect | Modbus/S7Comm | OPC-UA | +|--------|---------------|--------| +| Variable Access | I/O Image Tables only | Any PLC variable | +| Security | Basic or none | Multiple security profiles | +| Authentication | None | Anonymous, Username/Password, Certificate | +| Access Control | None | Role-based per-variable permissions | +| Data Types | Simple registers/coils | Variables, Structures, Arrays | +| Configuration Complexity | Low-Medium | High | From 8fd81ec5ad2519d6193a90e978f9476f71a07db9 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 15 Jan 2026 20:20:10 -0500 Subject: [PATCH 02/21] feat: Implement Phase 1 OPC-UA server configuration Add foundational OPC-UA server configuration support including: - Add OPC-UA type definitions (security profiles, users, certificates, permissions, address space nodes, server config) to open-plc.ts - Add 'opcua' protocol to server schemas (PLCServerProtocolSchema, editor types, tabs types) - Add OPC-UA option to server creation dropdown in element-card - Implement OPC-UA server initialization with default configuration - Create OpcUaServerEditor component with 5-tab interface: - General Settings (functional): server name, URIs, port, bind address, endpoint path, cycle time, enable toggle - Security Profiles (placeholder for Phase 2) - Users (placeholder for Phase 3) - Certificates (placeholder for Phase 4) - Address Space (placeholder for Phase 5) - Add updateOpcUaServerConfig action to project slice - Style OPC-UA editor to match existing Modbus/S7Comm server editors Co-Authored-By: Claude Opus 4.5 --- .../create-element/element-card/index.tsx | 3 +- .../[workspace]/editor/server/index.ts | 1 + .../editor/server/opcua-server/index.tsx | 471 ++++++++++++++++++ src/renderer/screens/workspace-screen.tsx | 7 +- src/renderer/store/slices/editor/types.ts | 2 +- src/renderer/store/slices/project/slice.ts | 102 ++++ src/renderer/store/slices/project/types.ts | 5 + src/renderer/store/slices/tabs/types.ts | 2 +- src/renderer/store/slices/tabs/utils.ts | 2 +- src/types/PLC/open-plc.ts | 159 +++++- 10 files changed, 748 insertions(+), 6 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx diff --git a/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx b/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx index 42fe2cc47..a68a01bed 100644 --- a/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx +++ b/src/renderer/components/_features/[workspace]/create-element/element-card/index.tsx @@ -37,7 +37,7 @@ type CreateDataTypeFormProps = { type CreateServerFormProps = { name: string - protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' + protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua' } type CreateRemoteDeviceFormProps = { @@ -48,6 +48,7 @@ type CreateRemoteDeviceFormProps = { const ServerProtocolSources = [ { value: 'modbus-tcp', label: 'Modbus/TCP', disabled: false }, { value: 's7comm', label: 'Siemens S7comm', disabled: false }, + { value: 'opcua', label: 'OPC-UA', disabled: false }, { value: 'ethernet-ip', label: 'EtherNet/IP', disabled: true }, ] as const diff --git a/src/renderer/components/_features/[workspace]/editor/server/index.ts b/src/renderer/components/_features/[workspace]/editor/server/index.ts index 3686bd98a..6448c3767 100644 --- a/src/renderer/components/_features/[workspace]/editor/server/index.ts +++ b/src/renderer/components/_features/[workspace]/editor/server/index.ts @@ -1,2 +1,3 @@ export { ModbusServerEditor } from './modbus-server' +export { OpcUaServerEditor } from './opcua-server' export { S7CommServerEditor } from './s7comm-server' diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx new file mode 100644 index 000000000..9fa6ab931 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx @@ -0,0 +1,471 @@ +import * as Tabs from '@radix-ui/react-tabs' +import { InputWithRef } from '@root/renderer/components/_atoms/input' +import { Label } from '@root/renderer/components/_atoms/label' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select' +import { useOpenPLCStore } from '@root/renderer/store' +import type { OpcUaServerConfig } from '@root/types/PLC/open-plc' +import { cn } from '@root/utils' +import { useCallback, useEffect, useMemo, useState } from 'react' + +/** + * OPC-UA Server Editor Component + * + * This component provides the configuration interface for the OPC-UA server. + * It consists of 5 tabs: + * - General Settings: Server name, port, endpoint configuration + * - Security Profiles: Security policies and authentication methods + * - Users: User accounts with roles and permissions + * - Certificates: Server and client certificate management + * - Address Space: Variable selection for OPC-UA exposure + */ + +const DEFAULT_NETWORK_INTERFACE_OPTIONS = [ + { value: '0.0.0.0', label: 'All Interfaces (0.0.0.0)' }, + { value: '127.0.0.1', label: 'Localhost (127.0.0.1)' }, +] + +// Input styles matching Modbus server editor +const inputStyles = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +// Tab item component +const TabItem = ({ value, label, isActive }: { value: string; label: string; isActive: boolean }) => ( + + {label} + +) + +// Placeholder component for tabs under development +const PlaceholderContent = ({ title, description }: { title: string; description: string }) => ( +
+
+

{title}

+

{description}

+
+
+) + +export const OpcUaServerEditor = () => { + const { + editor, + project, + projectActions: { updateOpcUaServerConfig }, + workspaceActions: { setEditingState }, + } = useOpenPLCStore() + + const [activeTab, setActiveTab] = useState('general') + + // Get the current server configuration + const serverName = editor.type === 'plc-server' ? editor.meta.name : '' + const protocol = editor.type === 'plc-server' ? editor.meta.protocol : '' + + const server = useMemo(() => { + return project.data.servers?.find((s) => s.name === serverName) + }, [project.data.servers, serverName]) + + const config = server?.opcuaServerConfig + + // Initialize local state from server config + const [localConfig, setLocalConfig] = useState(null) + + useEffect(() => { + if (config) { + setLocalConfig(config) + } + }, [config]) + + // Handle server settings updates + const handleServerSettingsUpdate = useCallback( + (updates: Partial) => { + if (!localConfig) return + + // Update local state immediately for responsive UI + setLocalConfig((prev) => { + if (!prev) return prev + return { + ...prev, + server: { ...prev.server, ...updates }, + } + }) + + // Persist to store + updateOpcUaServerConfig(serverName, { server: updates }) + setEditingState('unsaved') + }, + [localConfig, serverName, updateOpcUaServerConfig, setEditingState], + ) + + // Handle cycle time update (top-level config field) + const handleCycleTimeUpdate = useCallback( + (cycleTimeMs: number) => { + if (!localConfig) return + + setLocalConfig((prev) => { + if (!prev) return prev + return { ...prev, cycleTimeMs } + }) + + updateOpcUaServerConfig(serverName, { cycleTimeMs }) + setEditingState('unsaved') + }, + [localConfig, serverName, updateOpcUaServerConfig, setEditingState], + ) + + if (protocol !== 'opcua') { + return ( +
+

+ Configuration for {protocol} servers is not yet available. +

+
+ ) + } + + if (!localConfig) { + return ( +
+

Loading OPC-UA configuration...

+
+ ) + } + + return ( +
+
+

OPC-UA Server: {serverName}

+

Protocol: OPC-UA

+
+ + + {/* Tab Navigation */} + + + + + + + + + {/* General Settings Tab */} + +
+ +
+
+ + {/* Security Profiles Tab */} + + + + + {/* Users Tab */} + + + + + {/* Certificates Tab */} + + + + + {/* Address Space Tab */} + + + +
+
+ ) +} + +// General Settings Tab Component +interface GeneralSettingsTabProps { + config: OpcUaServerConfig + onServerUpdate: (updates: Partial) => void + onCycleTimeUpdate: (cycleTimeMs: number) => void +} + +const GeneralSettingsTab = ({ config, onServerUpdate, onCycleTimeUpdate }: GeneralSettingsTabProps) => { + // Local state for text inputs to allow typing before validation + const [port, setPort] = useState(config.server.port.toString()) + const [cycleTime, setCycleTime] = useState(config.cycleTimeMs.toString()) + const [serverDisplayName, setServerDisplayName] = useState(config.server.name) + const [applicationUri, setApplicationUri] = useState(config.server.applicationUri) + const [productUri, setProductUri] = useState(config.server.productUri) + const [endpointPath, setEndpointPath] = useState(config.server.endpointPath) + + // Sync local state when config changes + useEffect(() => { + setPort(config.server.port.toString()) + setCycleTime(config.cycleTimeMs.toString()) + setServerDisplayName(config.server.name) + setApplicationUri(config.server.applicationUri) + setProductUri(config.server.productUri) + setEndpointPath(config.server.endpointPath) + }, [config]) + + const handlePortBlur = useCallback(() => { + const portNum = parseInt(port, 10) + if (!isNaN(portNum) && portNum >= 1 && portNum <= 65535) { + if (portNum !== config.server.port) { + onServerUpdate({ port: portNum }) + } + } else { + setPort(config.server.port.toString()) + } + }, [port, config.server.port, onServerUpdate]) + + const handleCycleTimeBlur = useCallback(() => { + const value = parseInt(cycleTime, 10) + if (!isNaN(value) && value >= 10 && value <= 10000) { + if (value !== config.cycleTimeMs) { + onCycleTimeUpdate(value) + } + } else { + setCycleTime(config.cycleTimeMs.toString()) + } + }, [cycleTime, config.cycleTimeMs, onCycleTimeUpdate]) + + const handleServerNameBlur = useCallback(() => { + if (serverDisplayName !== config.server.name) { + onServerUpdate({ name: serverDisplayName }) + } + }, [serverDisplayName, config.server.name, onServerUpdate]) + + const handleApplicationUriBlur = useCallback(() => { + if (applicationUri !== config.server.applicationUri) { + onServerUpdate({ applicationUri }) + } + }, [applicationUri, config.server.applicationUri, onServerUpdate]) + + const handleProductUriBlur = useCallback(() => { + if (productUri !== config.server.productUri) { + onServerUpdate({ productUri }) + } + }, [productUri, config.server.productUri, onServerUpdate]) + + const handleEndpointPathBlur = useCallback(() => { + if (endpointPath !== config.server.endpointPath) { + onServerUpdate({ endpointPath }) + } + }, [endpointPath, config.server.endpointPath, onServerUpdate]) + + return ( +
+ {/* Server Configuration Section */} +
+

Server Configuration

+ + {/* Enable Server Toggle */} +
+ +
+ + {/* Application Identity Section */} +
+

Application Identity

+

+ Configure the OPC-UA application identity URIs. These are used by clients to identify the server. +

+ + {/* Application URI */} +
+ +
+ setApplicationUri(e.target.value)} + onBlur={handleApplicationUriBlur} + placeholder='urn:openplc:opcua:server' + className={inputStyles} + /> +
+
+ + {/* Product URI */} +
+ +
+ setProductUri(e.target.value)} + onBlur={handleProductUriBlur} + placeholder='urn:openplc:runtime' + className={inputStyles} + /> +
+
+
+ + {/* Timing Configuration Section */} +
+

Timing Configuration

+ + {/* Cycle Time */} +
+ +
+ setCycleTime(e.target.value)} + onBlur={handleCycleTimeBlur} + placeholder='100' + min='10' + max='10000' + className={inputStyles} + /> +
+ + Update frequency for OPC-UA variables (10-10000ms) + +
+
+ + {/* Namespace Configuration Section */} +
+

Namespace Configuration

+ + {/* Namespace URI */} +
+ +
+ +
+
+

+ The namespace URI for OpenPLC variables in the OPC-UA address space +

+
+
+ ) +} diff --git a/src/renderer/screens/workspace-screen.tsx b/src/renderer/screens/workspace-screen.tsx index f916254a0..ce1c3f853 100644 --- a/src/renderer/screens/workspace-screen.tsx +++ b/src/renderer/screens/workspace-screen.tsx @@ -13,7 +13,11 @@ import { DeviceEditor } from '../components/_features/[workspace]/editor/device' import { RemoteDeviceEditor } from '../components/_features/[workspace]/editor/device/remote-device' import { GraphicalEditor } from '../components/_features/[workspace]/editor/graphical' import { ResourcesEditor } from '../components/_features/[workspace]/editor/resource-editor' -import { ModbusServerEditor, S7CommServerEditor } from '../components/_features/[workspace]/editor/server' +import { + ModbusServerEditor, + OpcUaServerEditor, + S7CommServerEditor, +} from '../components/_features/[workspace]/editor/server' import { Search } from '../components/_features/[workspace]/search' import { VariablesPanel } from '../components/_molecules/variables-panel' import AboutModal from '../components/_organisms/about-modal' @@ -1652,6 +1656,7 @@ const WorkspaceScreen = () => { )} {editor['type'] === 'plc-server' && editor.meta.protocol === 's7comm' && } + {editor['type'] === 'plc-server' && editor.meta.protocol === 'opcua' && } {editor['type'] === 'plc-remote-device' && } {(editor['type'] === 'plc-textual' || editor['type'] === 'plc-graphical') && ( { }, } } + if (serverData.protocol === 'opcua' && !serverData.opcuaServerConfig) { + return { + ...serverData, + opcuaServerConfig: { + ...DEFAULT_OPCUA_SERVER_CONFIG, + securityProfiles: DEFAULT_OPCUA_SERVER_CONFIG.securityProfiles.map((profile) => ({ + ...profile, + id: uuidv4(), + })), + }, + } + } return serverData } @@ -1539,6 +1587,60 @@ const createProjectSlice: StateCreator = (se return response }, + /** + * OPC-UA Server Actions + */ + updateOpcUaServerConfig: (serverName, configUpdate): ProjectResponse => { + let response: ProjectResponse = { ok: true } + setState( + produce(({ project }: ProjectSlice) => { + if (!project.data.servers) { + response = { ok: false, message: 'No servers found' } + return + } + const server = project.data.servers.find((s) => s.name === serverName) + if (!server) { + response = { ok: false, message: 'Server not found' } + return + } + if (server.protocol !== 'opcua') { + response = { ok: false, message: 'Server is not an OPC-UA server' } + return + } + if (!server.opcuaServerConfig) { + response = { ok: false, message: 'OPC-UA configuration not found' } + return + } + + // Deep merge the config update + const mergeDeep = (target: Record, source: Record) => { + for (const key of Object.keys(source)) { + if (source[key] !== undefined) { + if ( + source[key] && + typeof source[key] === 'object' && + !Array.isArray(source[key]) && + target[key] && + typeof target[key] === 'object' && + !Array.isArray(target[key]) + ) { + mergeDeep(target[key] as Record, source[key] as Record) + } else { + target[key] = source[key] + } + } + } + } + + mergeDeep( + server.opcuaServerConfig as unknown as Record, + configUpdate as unknown as Record, + ) + }), + ) + return response + }, + /** * Remote Device Actions */ diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts index 3b4161fd4..a26bf9d85 100644 --- a/src/renderer/store/slices/project/types.ts +++ b/src/renderer/store/slices/project/types.ts @@ -385,6 +385,11 @@ const _projectActionsSchema = z.object({ .returns(projectResponseSchema), updateS7CommLogging: z.function().args(z.string(), S7CommLoggingSchema.partial()).returns(projectResponseSchema), + /** + * OPC-UA Server Actions + */ + updateOpcUaServerConfig: z.function().args(z.string(), z.record(z.unknown())).returns(projectResponseSchema), + /** * Remote Device Actions */ diff --git a/src/renderer/store/slices/tabs/types.ts b/src/renderer/store/slices/tabs/types.ts index 7d9f324e3..430c812a1 100644 --- a/src/renderer/store/slices/tabs/types.ts +++ b/src/renderer/store/slices/tabs/types.ts @@ -30,7 +30,7 @@ const tabsPropsSchema = z.object({ }), z.object({ type: z.literal('server'), - protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip']), + protocol: z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua']), }), z.object({ type: z.literal('remote-device'), diff --git a/src/renderer/store/slices/tabs/utils.ts b/src/renderer/store/slices/tabs/utils.ts index b48bc9e80..2b1699267 100644 --- a/src/renderer/store/slices/tabs/utils.ts +++ b/src/renderer/store/slices/tabs/utils.ts @@ -126,7 +126,7 @@ const CreateDeviceEditor = (name = 'device', derivation: 'configuration'): Edito throw new Error('Invalid derivation value') } -const CreateServerEditor = (name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip'): EditorModel => { +const CreateServerEditor = (name: string, protocol: 'modbus-tcp' | 's7comm' | 'ethernet-ip' | 'opcua'): EditorModel => { const editor = CreateEditorObject({ type: 'plc-server', meta: { diff --git a/src/types/PLC/open-plc.ts b/src/types/PLC/open-plc.ts index 972a78af2..c5c8a8f8f 100644 --- a/src/types/PLC/open-plc.ts +++ b/src/types/PLC/open-plc.ts @@ -276,7 +276,7 @@ const PLCConfigurationSchema = z.object({ }) type PLCConfiguration = z.infer -const PLCServerProtocolSchema = z.enum(['modbus-tcp', 's7comm', 'ethernet-ip']) +const PLCServerProtocolSchema = z.enum(['modbus-tcp', 's7comm', 'ethernet-ip', 'opcua']) type PLCServerProtocol = z.infer const ModbusSlaveBufferMappingSchema = z.object({ @@ -401,11 +401,140 @@ const S7CommSlaveConfigSchema = z.object({ }) type S7CommSlaveConfig = z.infer +// ═══════════════════════════════════════════════════════════════════════════ +// OPC-UA Server Configuration Types +// ═══════════════════════════════════════════════════════════════════════════ + +// OPC-UA Security Policy Enumeration +const OpcUaSecurityPolicySchema = z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']) +type OpcUaSecurityPolicy = z.infer + +// OPC-UA Security Mode Enumeration +const OpcUaSecurityModeSchema = z.enum(['None', 'Sign', 'SignAndEncrypt']) +type OpcUaSecurityMode = z.infer + +// OPC-UA Authentication Method Enumeration +const OpcUaAuthMethodSchema = z.enum(['Anonymous', 'Username', 'Certificate']) +type OpcUaAuthMethod = z.infer + +// OPC-UA User Role Enumeration +const OpcUaUserRoleSchema = z.enum(['viewer', 'operator', 'engineer']) +type OpcUaUserRole = z.infer + +// OPC-UA Security Profile Schema +const OpcUaSecurityProfileSchema = z.object({ + id: z.string(), + name: z.string().min(1).max(64), + enabled: z.boolean(), + securityPolicy: OpcUaSecurityPolicySchema, + securityMode: OpcUaSecurityModeSchema, + authMethods: z.array(OpcUaAuthMethodSchema).min(1), +}) +type OpcUaSecurityProfile = z.infer + +// OPC-UA Trusted Certificate Schema +const OpcUaTrustedCertificateSchema = z.object({ + id: z.string().min(1).max(64), + pem: z.string(), + subject: z.string().optional(), + validFrom: z.string().optional(), + validTo: z.string().optional(), + fingerprint: z.string().optional(), +}) +type OpcUaTrustedCertificate = z.infer + +// OPC-UA User Schema +const OpcUaUserSchema = z.object({ + id: z.string(), + type: z.enum(['password', 'certificate']), + username: z.string().nullable(), + passwordHash: z.string().nullable(), + certificateId: z.string().nullable(), + role: OpcUaUserRoleSchema, +}) +type OpcUaUser = z.infer + +// OPC-UA Permissions Schema (per-variable access control) +const OpcUaPermissionsSchema = z.object({ + viewer: z.enum(['r', 'w', 'rw']), + operator: z.enum(['r', 'w', 'rw']), + engineer: z.enum(['r', 'w', 'rw']), +}) +type OpcUaPermissions = z.infer + +// OPC-UA Field Configuration Schema (for structure fields) +const OpcUaFieldConfigSchema = z.object({ + fieldPath: z.string(), + displayName: z.string(), + initialValue: z.union([z.boolean(), z.number(), z.string()]), + permissions: OpcUaPermissionsSchema, +}) +type OpcUaFieldConfig = z.infer + +// OPC-UA Node Configuration Schema (variable/structure/array) +const OpcUaNodeConfigSchema = z.object({ + id: z.string(), + pouName: z.string(), + variablePath: z.string(), + variableType: z.string(), + nodeId: z.string(), + browseName: z.string(), + displayName: z.string(), + description: z.string(), + initialValue: z.union([z.boolean(), z.number(), z.string()]), + permissions: OpcUaPermissionsSchema, + nodeType: z.enum(['variable', 'structure', 'array']), + fields: z.array(OpcUaFieldConfigSchema).optional(), + arrayLength: z.number().optional(), + elementType: z.string().optional(), +}) +type OpcUaNodeConfig = z.infer + +// OPC-UA Server Settings Schema +const OpcUaServerSettingsSchema = z.object({ + enabled: z.boolean(), + name: z.string().max(128), + applicationUri: z.string(), + productUri: z.string(), + bindAddress: z.string(), + port: z.number().int().min(1).max(65535), + endpointPath: z.string(), +}) +type OpcUaServerSettings = z.infer + +// OPC-UA Security Configuration Schema +const OpcUaSecurityConfigSchema = z.object({ + serverCertificateStrategy: z.enum(['auto_self_signed', 'custom']), + serverCertificateCustom: z.string().nullable(), + serverPrivateKeyCustom: z.string().nullable(), + trustedClientCertificates: z.array(OpcUaTrustedCertificateSchema), +}) +type OpcUaSecurityConfig = z.infer + +// OPC-UA Address Space Configuration Schema +const OpcUaAddressSpaceConfigSchema = z.object({ + namespaceUri: z.string(), + nodes: z.array(OpcUaNodeConfigSchema), +}) +type OpcUaAddressSpaceConfig = z.infer + +// Complete OPC-UA Server Configuration Schema +const OpcUaServerConfigSchema = z.object({ + server: OpcUaServerSettingsSchema, + securityProfiles: z.array(OpcUaSecurityProfileSchema), + security: OpcUaSecurityConfigSchema, + users: z.array(OpcUaUserSchema), + cycleTimeMs: z.number().int().min(10).max(10000), + addressSpace: OpcUaAddressSpaceConfigSchema, +}) +type OpcUaServerConfig = z.infer + const PLCServerSchema = z.object({ name: z.string(), protocol: PLCServerProtocolSchema, modbusSlaveConfig: ModbusSlaveConfigSchema.optional(), s7commSlaveConfig: S7CommSlaveConfigSchema.optional(), + opcuaServerConfig: OpcUaServerConfigSchema.optional(), }) type PLCServer = z.infer @@ -528,6 +657,20 @@ export { ModbusSlaveBufferMappingSchema, ModbusSlaveConfigSchema, ModbusTcpConfigSchema, + OpcUaAddressSpaceConfigSchema, + OpcUaAuthMethodSchema, + OpcUaFieldConfigSchema, + OpcUaNodeConfigSchema, + OpcUaPermissionsSchema, + OpcUaSecurityConfigSchema, + OpcUaSecurityModeSchema, + OpcUaSecurityPolicySchema, + OpcUaSecurityProfileSchema, + OpcUaServerConfigSchema, + OpcUaServerSettingsSchema, + OpcUaTrustedCertificateSchema, + OpcUaUserRoleSchema, + OpcUaUserSchema, PLCArrayDatatypeSchema, PLCConfigurationSchema, PLCDataTypeSchema, @@ -571,6 +714,20 @@ export type { ModbusSlaveBufferMapping, ModbusSlaveConfig, ModbusTcpConfig, + OpcUaAddressSpaceConfig, + OpcUaAuthMethod, + OpcUaFieldConfig, + OpcUaNodeConfig, + OpcUaPermissions, + OpcUaSecurityConfig, + OpcUaSecurityMode, + OpcUaSecurityPolicy, + OpcUaSecurityProfile, + OpcUaServerConfig, + OpcUaServerSettings, + OpcUaTrustedCertificate, + OpcUaUser, + OpcUaUserRole, PLCArrayDatatype, PLCConfiguration, PLCDataType, From 088765db6105a461fb7ba2b4b135e7d7f55c9e56 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 15 Jan 2026 20:51:25 -0500 Subject: [PATCH 03/21] feat: Implement Phase 2 OPC-UA Security Profiles tab - Add security profile CRUD actions to project slice: - addOpcUaSecurityProfile: Add new profile with duplicate name validation - updateOpcUaSecurityProfile: Update existing profile - removeOpcUaSecurityProfile: Remove profile (prevents deleting last one) - Create SecurityProfileModal component with validation: - Anonymous auth only with "None" security policy - Security Mode must match Policy constraints - Profile names must be unique - At least one auth method required - Create SecurityProfilesTab component: - List profiles with enable/disable toggles - Add/Edit/Delete functionality - Warning for insecure profiles (None policy) - Prevents disabling last enabled profile - Update OpcUaServerEditor to use SecurityProfilesTab Co-Authored-By: Claude Opus 4.5 --- .../components/security-profile-modal.tsx | 383 ++++++++++++++++++ .../components/security-profiles-tab.tsx | 230 +++++++++++ .../editor/server/opcua-server/index.tsx | 13 +- src/renderer/store/slices/project/slice.ts | 111 ++++- src/renderer/store/slices/project/types.ts | 31 ++ 5 files changed, 763 insertions(+), 5 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx new file mode 100644 index 000000000..361ebc89d --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profile-modal.tsx @@ -0,0 +1,383 @@ +import { Checkbox } from '@root/renderer/components/_atoms/checkbox' +import { InputWithRef } from '@root/renderer/components/_atoms/input' +import { Label } from '@root/renderer/components/_atoms/label' +import { Select, SelectContent, SelectItem, SelectTrigger } from '@root/renderer/components/_atoms/select' +import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal' +import type { OpcUaSecurityProfile } from '@root/types/PLC/open-plc' +import { cn } from '@root/utils' +import { useCallback, useEffect, useMemo, useState } from 'react' +import { v4 as uuidv4 } from 'uuid' + +type SecurityPolicy = 'None' | 'Basic128Rsa15' | 'Basic256' | 'Basic256Sha256' +type SecurityMode = 'None' | 'Sign' | 'SignAndEncrypt' +type AuthMethod = 'Anonymous' | 'Username' | 'Certificate' + +interface SecurityProfileModalProps { + isOpen: boolean + onClose: () => void + onSave: (profile: OpcUaSecurityProfile) => void + existingProfile?: OpcUaSecurityProfile + existingNames: string[] +} + +const inputStyles = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +const SECURITY_POLICIES: { value: SecurityPolicy; label: string }[] = [ + { value: 'None', label: 'None (No Security)' }, + { value: 'Basic128Rsa15', label: 'Basic128Rsa15' }, + { value: 'Basic256', label: 'Basic256' }, + { value: 'Basic256Sha256', label: 'Basic256Sha256 (Recommended)' }, +] + +const SECURITY_MODES: { value: SecurityMode; label: string }[] = [ + { value: 'None', label: 'None' }, + { value: 'Sign', label: 'Sign Only' }, + { value: 'SignAndEncrypt', label: 'Sign and Encrypt' }, +] + +export const SecurityProfileModal = ({ + isOpen, + onClose, + onSave, + existingProfile, + existingNames, +}: SecurityProfileModalProps) => { + const isEditing = !!existingProfile + + // Form state + const [name, setName] = useState('') + const [enabled, setEnabled] = useState(true) + const [securityPolicy, setSecurityPolicy] = useState('None') + const [securityMode, setSecurityMode] = useState('None') + const [authMethods, setAuthMethods] = useState(['Anonymous']) + + // Reset form when modal opens/closes or profile changes + useEffect(() => { + if (isOpen && existingProfile) { + setName(existingProfile.name) + setEnabled(existingProfile.enabled) + setSecurityPolicy(existingProfile.securityPolicy) + setSecurityMode(existingProfile.securityMode) + setAuthMethods(existingProfile.authMethods) + } else if (isOpen && !existingProfile) { + // Reset to defaults for new profile + setName('') + setEnabled(true) + setSecurityPolicy('None') + setSecurityMode('None') + setAuthMethods(['Anonymous']) + } + }, [isOpen, existingProfile]) + + // Validation rules + const validationErrors = useMemo(() => { + const errors: string[] = [] + + // Name validation + if (!name.trim()) { + errors.push('Profile name is required') + } else if (name.length > 64) { + errors.push('Profile name must be 64 characters or less') + } else { + // Check for duplicate name (case insensitive), excluding current profile when editing + const isDuplicate = existingNames.some( + (existingName) => + existingName.toLowerCase() === name.trim().toLowerCase() && + (!existingProfile || existingName.toLowerCase() !== existingProfile.name.toLowerCase()), + ) + if (isDuplicate) { + errors.push('A profile with this name already exists') + } + } + + // Security Policy + Mode validation + if (securityPolicy === 'None' && securityMode !== 'None') { + errors.push('Security Mode must be "None" when Security Policy is "None"') + } + if (securityPolicy !== 'None' && securityMode === 'None') { + errors.push('Security Mode cannot be "None" when using a security policy') + } + + // Anonymous validation + if (authMethods.includes('Anonymous') && securityPolicy !== 'None') { + errors.push('Anonymous authentication is only available with Security Policy "None"') + } + + // At least one auth method required + if (authMethods.length === 0) { + errors.push('At least one authentication method is required') + } + + return errors + }, [name, securityPolicy, securityMode, authMethods, existingNames, existingProfile]) + + const isValid = validationErrors.length === 0 + + // Handle security policy change - auto-adjust mode if needed + const handleSecurityPolicyChange = useCallback((policy: SecurityPolicy) => { + setSecurityPolicy(policy) + if (policy === 'None') { + setSecurityMode('None') + // Anonymous is only valid with None policy, so add it if no auth methods + setAuthMethods((prev) => { + if (prev.length === 0) return ['Anonymous'] + return prev + }) + } else { + // If switching from None to a real policy, remove Anonymous and set a valid mode + setAuthMethods((prev) => prev.filter((m) => m !== 'Anonymous')) + setSecurityMode((prev) => (prev === 'None' ? 'SignAndEncrypt' : prev)) + } + }, []) + + // Handle auth method toggle + const toggleAuthMethod = useCallback( + (method: AuthMethod) => { + setAuthMethods((prev) => { + if (prev.includes(method)) { + // Don't allow removing the last auth method + if (prev.length === 1) return prev + return prev.filter((m) => m !== method) + } + // Don't allow Anonymous with non-None policy + if (method === 'Anonymous' && securityPolicy !== 'None') { + return prev + } + return [...prev, method] + }) + }, + [securityPolicy], + ) + + // Handle save + const handleSave = useCallback(() => { + if (!isValid) return + + const profile: OpcUaSecurityProfile = { + id: existingProfile?.id ?? uuidv4(), + name: name.trim(), + enabled, + securityPolicy, + securityMode, + authMethods, + } + + onSave(profile) + onClose() + }, [isValid, name, enabled, securityPolicy, securityMode, authMethods, existingProfile, onSave, onClose]) + + return ( + !open && onClose()}> + + + {isEditing ? 'Edit Security Profile' : 'Add Security Profile'} + + +
+ {/* Profile Name */} +
+ + setName(e.target.value)} + placeholder='e.g., secure_encrypted' + maxLength={64} + className={inputStyles} + /> + Unique identifier for this profile +
+ + {/* Enable Toggle */} +
+ +
+ + + + + + + + ) +} diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx new file mode 100644 index 000000000..291832fa4 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/security-profiles-tab.tsx @@ -0,0 +1,230 @@ +import { useOpenPLCStore } from '@root/renderer/store' +import type { OpcUaSecurityProfile, OpcUaServerConfig } from '@root/types/PLC/open-plc' +import { cn } from '@root/utils' +import { useCallback, useMemo, useState } from 'react' + +import { SecurityProfileModal } from './security-profile-modal' + +interface SecurityProfilesTabProps { + config: OpcUaServerConfig + serverName: string + onConfigChange: () => void +} + +// Helper to get a human-readable description for a security profile +const getProfileDescription = (profile: OpcUaSecurityProfile): string => { + if (profile.securityPolicy === 'None') { + return 'No encryption or authentication. Use only for development/testing.' + } + if (profile.securityMode === 'Sign') { + return 'Messages are signed but not encrypted.' + } + return 'Full security: messages are signed and encrypted.' +} + +// Helper to format auth methods for display +const formatAuthMethods = (methods: OpcUaSecurityProfile['authMethods']): string => { + return methods.join(', ') +} + +export const SecurityProfilesTab = ({ config, serverName, onConfigChange }: SecurityProfilesTabProps) => { + const { + projectActions: { addOpcUaSecurityProfile, updateOpcUaSecurityProfile, removeOpcUaSecurityProfile }, + } = useOpenPLCStore() + + const [isModalOpen, setIsModalOpen] = useState(false) + const [editingProfile, setEditingProfile] = useState(undefined) + + // Get existing profile names for validation + const existingNames = useMemo(() => config.securityProfiles.map((p) => p.name), [config.securityProfiles]) + + // Check if at least one profile is enabled + const hasEnabledProfile = useMemo(() => config.securityProfiles.some((p) => p.enabled), [config.securityProfiles]) + + // Handle adding a new profile + const handleAddProfile = useCallback(() => { + setEditingProfile(undefined) + setIsModalOpen(true) + }, []) + + // Handle editing an existing profile + const handleEditProfile = useCallback((profile: OpcUaSecurityProfile) => { + setEditingProfile(profile) + setIsModalOpen(true) + }, []) + + // Handle saving a profile (add or update) + const handleSaveProfile = useCallback( + (profile: OpcUaSecurityProfile) => { + if (editingProfile) { + updateOpcUaSecurityProfile(serverName, profile.id, profile) + } else { + addOpcUaSecurityProfile(serverName, profile) + } + onConfigChange() + }, + [serverName, editingProfile, addOpcUaSecurityProfile, updateOpcUaSecurityProfile, onConfigChange], + ) + + // Handle deleting a profile + const handleDeleteProfile = useCallback( + (profileId: string) => { + const result = removeOpcUaSecurityProfile(serverName, profileId) + if (result.ok) { + onConfigChange() + } + }, + [serverName, removeOpcUaSecurityProfile, onConfigChange], + ) + + // Handle toggling profile enabled state + const handleToggleEnabled = useCallback( + (profile: OpcUaSecurityProfile) => { + // Don't allow disabling if it's the last enabled profile + if (profile.enabled) { + const enabledCount = config.securityProfiles.filter((p) => p.enabled).length + if (enabledCount <= 1) { + return // Can't disable the last enabled profile + } + } + updateOpcUaSecurityProfile(serverName, profile.id, { enabled: !profile.enabled }) + onConfigChange() + }, + [serverName, config.securityProfiles, updateOpcUaSecurityProfile, onConfigChange], + ) + + return ( +
+ {/* Header and description */} +
+

+ Configure which security profiles clients can use to connect. At least one profile must be enabled. +

+
+ + {/* Warning if no profiles are enabled */} + {!hasEnabledProfile && ( +
+

+ Warning: At least one security profile must be enabled for clients to connect. +

+
+ )} + + {/* Add Profile Button */} + + + {/* Security Profiles List */} +
+ {config.securityProfiles.map((profile) => ( +
+ {/* Header row with toggle, name, and actions */} +
+
+ {/* Enable Toggle */} +
+ + {/* Profile Details */} +
+

+ Policy: {profile.securityPolicy} + {' | '} + Mode: {profile.securityMode} +

+

+ Authentication: {formatAuthMethods(profile.authMethods)} +

+

+ {getProfileDescription(profile)} +

+ + {/* Warning for insecure profiles */} + {profile.securityPolicy === 'None' && profile.enabled && ( +
+ ! +

+ Warning: No encryption or authentication. Use only for development/testing. +

+
+ )} +
+
+ ))} +
+ + {/* Note about certificates */} +
+

+ Note: Security profiles with Certificate authentication require trusted client certificates to be configured + in the Certificates tab. +

+
+ + {/* Modal for adding/editing profiles */} + setIsModalOpen(false)} + onSave={handleSaveProfile} + existingProfile={editingProfile} + existingNames={existingNames} + /> +
+ ) +} diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx index 9fa6ab931..8a74992ca 100644 --- a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx +++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/index.tsx @@ -7,6 +7,8 @@ import type { OpcUaServerConfig } from '@root/types/PLC/open-plc' import { cn } from '@root/utils' import { useCallback, useEffect, useMemo, useState } from 'react' +import { SecurityProfilesTab } from './components/security-profiles-tab' + /** * OPC-UA Server Editor Component * @@ -169,10 +171,13 @@ export const OpcUaServerEditor = () => { {/* Security Profiles Tab */} - +
+ setEditingState('unsaved')} + /> +
{/* Users Tab */} diff --git a/src/renderer/store/slices/project/slice.ts b/src/renderer/store/slices/project/slice.ts index f1a6fa87b..286ce1ed6 100644 --- a/src/renderer/store/slices/project/slice.ts +++ b/src/renderer/store/slices/project/slice.ts @@ -1,7 +1,7 @@ import { toast } from '@root/renderer/components/_features/[app]/toast/use-toast' +import type { OpcUaSecurityProfile, OpcUaServerConfig } from '@root/types/PLC/open-plc' import { ModbusIOPoint, - OpcUaServerConfig, PLCArrayDatatype, PLCDataType, PLCGlobalVariable, @@ -1641,6 +1641,115 @@ const createProjectSlice: StateCreator = (se return response }, + addOpcUaSecurityProfile: (serverName: string, profile: OpcUaSecurityProfile): ProjectResponse => { + let response: ProjectResponse = { ok: true } + setState( + produce(({ project }: ProjectSlice) => { + if (!project.data.servers) { + response = { ok: false, message: 'No servers found' } + return + } + const server = project.data.servers.find((s) => s.name === serverName) + if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) { + response = { ok: false, message: 'OPC-UA server not found' } + return + } + // Check for duplicate profile name + const nameExists = server.opcuaServerConfig.securityProfiles.some( + (p) => p.name.toLowerCase() === profile.name.toLowerCase(), + ) + if (nameExists) { + response = { ok: false, message: `A security profile named "${profile.name}" already exists` } + toast({ + title: 'Invalid Security Profile', + description: `A security profile named "${profile.name}" already exists.`, + variant: 'fail', + }) + return + } + server.opcuaServerConfig.securityProfiles.push(profile) + }), + ) + return response + }, + + updateOpcUaSecurityProfile: ( + serverName: string, + profileId: string, + updates: Partial, + ): ProjectResponse => { + let response: ProjectResponse = { ok: true } + setState( + produce(({ project }: ProjectSlice) => { + if (!project.data.servers) { + response = { ok: false, message: 'No servers found' } + return + } + const server = project.data.servers.find((s) => s.name === serverName) + if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) { + response = { ok: false, message: 'OPC-UA server not found' } + return + } + const profile = server.opcuaServerConfig.securityProfiles.find((p) => p.id === profileId) + if (!profile) { + response = { ok: false, message: 'Security profile not found' } + return + } + // Check for duplicate name if changing + if (updates.name !== undefined && updates.name.toLowerCase() !== profile.name.toLowerCase()) { + const nameExists = server.opcuaServerConfig.securityProfiles.some( + (p) => p.id !== profileId && p.name.toLowerCase() === updates.name!.toLowerCase(), + ) + if (nameExists) { + response = { ok: false, message: `A security profile named "${updates.name}" already exists` } + toast({ + title: 'Invalid Security Profile', + description: `A security profile named "${updates.name}" already exists.`, + variant: 'fail', + }) + return + } + } + Object.assign(profile, updates) + }), + ) + return response + }, + + removeOpcUaSecurityProfile: (serverName: string, profileId: string): ProjectResponse => { + let response: ProjectResponse = { ok: true } + setState( + produce(({ project }: ProjectSlice) => { + if (!project.data.servers) { + response = { ok: false, message: 'No servers found' } + return + } + const server = project.data.servers.find((s) => s.name === serverName) + if (!server || server.protocol !== 'opcua' || !server.opcuaServerConfig) { + response = { ok: false, message: 'OPC-UA server not found' } + return + } + const index = server.opcuaServerConfig.securityProfiles.findIndex((p) => p.id === profileId) + if (index === -1) { + response = { ok: false, message: 'Security profile not found' } + return + } + // Prevent deleting the last profile + if (server.opcuaServerConfig.securityProfiles.length <= 1) { + response = { ok: false, message: 'At least one security profile is required' } + toast({ + title: 'Cannot Delete Profile', + description: 'At least one security profile must exist.', + variant: 'fail', + }) + return + } + server.opcuaServerConfig.securityProfiles.splice(index, 1) + }), + ) + return response + }, + /** * Remote Device Actions */ diff --git a/src/renderer/store/slices/project/types.ts b/src/renderer/store/slices/project/types.ts index a26bf9d85..050df0923 100644 --- a/src/renderer/store/slices/project/types.ts +++ b/src/renderer/store/slices/project/types.ts @@ -389,6 +389,37 @@ const _projectActionsSchema = z.object({ * OPC-UA Server Actions */ updateOpcUaServerConfig: z.function().args(z.string(), z.record(z.unknown())).returns(projectResponseSchema), + addOpcUaSecurityProfile: z + .function() + .args( + z.string(), + z.object({ + id: z.string(), + name: z.string(), + enabled: z.boolean(), + securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']), + securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']), + authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])), + }), + ) + .returns(projectResponseSchema), + updateOpcUaSecurityProfile: z + .function() + .args( + z.string(), + z.string(), + z + .object({ + name: z.string(), + enabled: z.boolean(), + securityPolicy: z.enum(['None', 'Basic128Rsa15', 'Basic256', 'Basic256Sha256']), + securityMode: z.enum(['None', 'Sign', 'SignAndEncrypt']), + authMethods: z.array(z.enum(['Anonymous', 'Username', 'Certificate'])), + }) + .partial(), + ) + .returns(projectResponseSchema), + removeOpcUaSecurityProfile: z.function().args(z.string(), z.string()).returns(projectResponseSchema), /** * Remote Device Actions From ec1ec87b2922cb503600a26a808117a6cf9c4355 Mon Sep 17 00:00:00 2001 From: Thiago Alves Date: Thu, 15 Jan 2026 21:17:45 -0500 Subject: [PATCH 04/21] feat: Implement Phase 3 OPC-UA Users and Certificates tabs Users Tab: - Add user CRUD actions (addOpcUaUser, updateOpcUaUser, removeOpcUaUser) - UserModal with password/certificate authentication type selector - Password hashing using SHA-256 (Web Crypto API) - Role selector (Viewer/Operator/Engineer) with descriptions - Certificate user binding to trusted certificates - Validation for duplicate usernames and certificate bindings Certificates Tab: - Server certificate strategy (auto_self_signed/custom) - Custom certificate and private key upload with file browse - Trusted client certificates list with Add/Delete - Certificate ID validation and PEM format validation - Prevents deleting certificates bound to users Co-Authored-By: Claude Opus 4.5 --- .../components/certificate-modal.tsx | 263 ++++++++++++ .../components/certificates-tab.tsx | 327 ++++++++++++++ .../opcua-server/components/user-modal.tsx | 406 ++++++++++++++++++ .../opcua-server/components/users-tab.tsx | 237 ++++++++++ .../editor/server/opcua-server/index.tsx | 20 +- src/renderer/store/slices/project/slice.ts | 230 +++++++++- src/renderer/store/slices/project/types.ts | 63 +++ 7 files changed, 1537 insertions(+), 9 deletions(-) create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificates-tab.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/user-modal.tsx create mode 100644 src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/users-tab.tsx diff --git a/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx new file mode 100644 index 000000000..12eb5f252 --- /dev/null +++ b/src/renderer/components/_features/[workspace]/editor/server/opcua-server/components/certificate-modal.tsx @@ -0,0 +1,263 @@ +import { InputWithRef } from '@root/renderer/components/_atoms/input' +import { Label } from '@root/renderer/components/_atoms/label' +import { Modal, ModalContent, ModalFooter, ModalHeader, ModalTitle } from '@root/renderer/components/_molecules/modal' +import type { OpcUaTrustedCertificate } from '@root/types/PLC/open-plc' +import { cn } from '@root/utils' +import { useCallback, useEffect, useMemo, useState } from 'react' + +interface CertificateModalProps { + isOpen: boolean + onClose: () => void + onSave: (certificate: OpcUaTrustedCertificate) => void + existingCertificateIds: string[] +} + +const inputStyles = + 'h-[30px] w-full rounded-md border border-neutral-300 bg-white px-2 py-1 font-caption text-cp-sm font-medium text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +const textareaStyles = + 'w-full rounded-md border border-neutral-300 bg-white px-2 py-2 font-mono text-xs text-neutral-850 outline-none focus:border-brand-medium-dark dark:border-neutral-850 dark:bg-neutral-950 dark:text-neutral-300' + +// Simple PEM validation and info extraction +// Note: For production, consider using a proper certificate parsing library like node-forge +const parsePemCertificate = (pem: string): { valid: boolean; subject?: string; error?: string } => { + const trimmed = pem.trim() + + if (!trimmed.startsWith('-----BEGIN CERTIFICATE-----')) { + return { valid: false, error: 'Certificate must start with "-----BEGIN CERTIFICATE-----"' } + } + + if (!trimmed.endsWith('-----END CERTIFICATE-----')) { + return { valid: false, error: 'Certificate must end with "-----END CERTIFICATE-----"' } + } + + // Extract the base64 content + const lines = trimmed.split('\n') + const contentLines = lines.slice(1, -1) + const base64Content = contentLines.join('').trim() + + if (base64Content.length < 100) { + return { valid: false, error: 'Certificate content appears to be too short' } + } + + // Try to validate base64 + try { + atob(base64Content) + } catch { + return { valid: false, error: 'Certificate contains invalid base64 content' } + } + + // Certificate appears valid - we can't parse the details without a proper library + // but we can indicate it's a valid PEM format + return { valid: true, subject: '(Certificate details available after adding)' } +} + +// Generate a simple fingerprint for display (not cryptographically secure) +const generateSimpleFingerprint = (pem: string): string => { + const hash = Array.from(pem) + .reduce((acc, char) => ((acc << 5) - acc + char.charCodeAt(0)) | 0, 0) + .toString(16) + .toUpperCase() + .padStart(8, '0') + return `${hash.slice(0, 2)}:${hash.slice(2, 4)}:${hash.slice(4, 6)}:${hash.slice(6, 8)}:...` +} + +export const CertificateModal = ({ isOpen, onClose, onSave, existingCertificateIds }: CertificateModalProps) => { + // Form state + const [certificateId, setCertificateId] = useState('') + const [pemContent, setPemContent] = useState('') + const [pemError, setPemError] = useState(null) + + // Reset form when modal opens/closes + useEffect(() => { + if (isOpen) { + setCertificateId('') + setPemContent('') + setPemError(null) + } + }, [isOpen]) + + // Validate PEM when content changes + useEffect(() => { + if (pemContent.trim()) { + const result = parsePemCertificate(pemContent) + if (!result.valid) { + setPemError(result.error ?? 'Invalid certificate') + } else { + setPemError(null) + } + } else { + setPemError(null) + } + }, [pemContent]) + + // Certificate preview info + const certificateInfo = useMemo(() => { + if (!pemContent.trim() || pemError) return null + return { + fingerprint: generateSimpleFingerprint(pemContent), + } + }, [pemContent, pemError]) + + // Validation rules + const validationErrors = useMemo(() => { + const errors: string[] = [] + + // Certificate ID validation + if (!certificateId.trim()) { + errors.push('Certificate ID is required') + } else if (certificateId.length > 64) { + errors.push('Certificate ID must be 64 characters or less') + } else if (!/^[a-zA-Z0-9_-]+$/.test(certificateId.trim())) { + errors.push('Certificate ID can only contain letters, numbers, hyphens, and underscores') + } else { + // Check for duplicate ID + const isDuplicate = existingCertificateIds.some((id) => id.toLowerCase() === certificateId.trim().toLowerCase()) + if (isDuplicate) { + errors.push('A certificate with this ID already exists') + } + } + + // PEM validation + if (!pemContent.trim()) { + errors.push('Certificate PEM content is required') + } else if (pemError) { + errors.push(pemError) + } + + return errors + }, [certificateId, pemContent, pemError, existingCertificateIds]) + + const isValid = validationErrors.length === 0 + + // Handle file browse + const handleBrowseFile = useCallback(() => { + // Use the file input approach since we're in a web context + const input = document.createElement('input') + input.type = 'file' + input.accept = '.pem,.crt,.cer' + input.onchange = (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + if (file) { + void file.text().then((content) => { + setPemContent(content) + }) + } + } + input.click() + }, []) + + // Handle save + const handleSave = useCallback(() => { + if (!isValid) return + + const certificate: OpcUaTrustedCertificate = { + id: certificateId.trim(), + pem: pemContent.trim(), + fingerprint: certificateInfo?.fingerprint, + } + + onSave(certificate) + onClose() + }, [isValid, certificateId, pemContent, certificateInfo, onSave, onClose]) + + return ( + !open && onClose()}> + + + Add Trusted Client Certificate + + +
+ {/* Certificate ID */} +
+ + setCertificateId(e.target.value)} + placeholder='e.g., engineer_client' + maxLength={64} + className={inputStyles} + /> + + Unique identifier used to reference this certificate in user configuration + +
+ + {/* PEM Content */} +
+ +