diff --git a/.gitignore b/.gitignore index 9690405a2ef..ce811fb7808 100644 --- a/.gitignore +++ b/.gitignore @@ -180,6 +180,12 @@ build-iPhoneSimulator/ # Temp stuff ... /plc4go/.idea/ + +# Binaries left behind by building the in-repo Go tools directly ("go build ./tools/..."). +# Nothing needs them checked in: go.mod declares them with 'tool' directives, so +# "go tool plc4xGenerator" compiles them from source on demand. +/plc4go/plc4xGenerator +/plc4go/plc4xLicencer plc4j/examples/hello-storage-elasticsearch/.factorypath diff --git a/RELEASE_NOTES b/RELEASE_NOTES index 99964060e8f..ce08aa57d9b 100644 --- a/RELEASE_NOTES +++ b/RELEASE_NOTES @@ -97,6 +97,156 @@ New Features Incompatible changes -------------------- +- Configuration parameters now use one vocabulary across PLC4J and PLC4Go. + A duration in milliseconds ends in "-ms", TLS settings live under "tls.", + and a parameter aimed at a transport no longer repeats that transport's + code. Old names are removed rather than deprecated: supplying one is + reported as an unknown parameter, naming the replacement, and the setting + does not apply. The full table is below. + + Durations: + + request-timeout -> request-timeout-ms + timeout-request (ads) -> request-timeout-ms + connect-timeout -> connect-timeout-ms + read-timeout -> read-timeout-ms + write-timeout -> write-timeout-ms + session-timeout -> session-timeout-ms + channel-lifetime -> channel-lifetime-ms + min-channel-lifetime -> min-channel-lifetime-ms + ha-heartbeat-interval -> ha-heartbeat-interval-ms + ha-failover-timeout -> ha-failover-timeout-ms + + Establishing a socket and completing a protocol handshake are two + settings, not one, so they now have two names. "connect-timeout-ms" is + the socket connect; the COTP handshake and the OPC UA negotiation steps + are "handshake-timeout-ms": + + cotp.cotp-connection-timeout -> cotp.handshake-timeout-ms + negotiation-timeout (opcua) -> handshake-timeout-ms + + Transport parameters no longer repeat their transport's code, which the + prefix already supplies: + + tcp.tcp-no-delay -> tcp.no-delay + cotp.cotp-tpdu-size -> cotp.tpdu-size + tls.tls-version -> tls.version + + TLS settings are addressed under "tls.": + + tls.verify-ssl -> tls.verify + key-store-file (opcua) -> tls.keystore + key-store-password -> tls.keystore-password + key-store-type -> tls.keystore-type + trust-store-file -> tls.trust-store + trust-store-password -> tls.trust-store-password + trust-store-type -> tls.trust-store-type + + The trust store drops "-file" for the same reason the key store does: + every one of these names a store, so saying so adds nothing. The TLS + transport already spelled them "tls.trust-store-file"; that becomes + "tls.trust-store" too, so the opcua and ctrlx drivers, which declare + their own, now agree with it. + + A name a protocol specification fixes keeps its own spelling and units: + SLMP's "monitoring-timer" is a field of the 3E request frame in the + protocol's own units, not a value in milliseconds, so it is unchanged and + carries a comment at its declaration saying why. + +- The OPC UA driver's "insecure-certificate-verification" became + "tls.verify", with the opposite sense. A connection that set + "insecure-certificate-verification=true" must now set "tls.verify=false". + This one is not just a rename: if it is missed, the new default applies, + which is to verify the server certificate. That fails loudly against a + server whose certificate does not validate rather than connecting + insecurely, but it is a behaviour change and not a silent one. + +- An unrecognised connection-string parameter is now reported in PLC4Go as + well as PLC4J, naming the parameter and, where it can, the nearest known + one. It remains a warning: a stray parameter does not fail a connection + that would otherwise work. PLC4Go's OPC UA driver previously *refused* + the connection on an unknown option; it now warns like every other + driver, so a connection string accepted by PLC4J is no longer rejected + there. + + In PLC4Go this covers the drivers that parse their configuration in one + place: ab-eth, bacnet-ip, c-bus, EtherNet/IP, firmata, IEC 60870-5-104, + Modbus, OPC UA, S7, SLMP and UMAS. The ADS, KNXnet/IP and simulated + drivers read their options where they are used rather than parsing a + configuration, so there is no point at which the leftovers are known; + they are unchanged and still report nothing. + + The report also knows which transport the connection actually uses, so a + parameter that belongs to a different transport - "serial.baud-rate" on a + TCP connection - is called out as misdirected instead of being silently + excused as "some transport's". + + A suggestion is offered only among the names the consumer that reported + actually read, so a parameter belonging to a transport is named as + unknown with nothing to suggest. PLC4J does better here: it draws the + known names from the driver, the transport, the audit log and the + connection-control options, and matches on the last segment, so a + missing prefix is recognised for what it is. + +- Configuration values carrying secrets are marked at their declaration - + "@Secret" in PLC4J, a `secret:"true"` struct tag in PLC4Go - and render + as "" wherever a configuration is rendered. This replaces + guessing from parameter names, which could only ever be one parameter + behind: a pre-shared key was logged in clear until its name was added to + the list by hand. A name-based check remains for parameters no + configuration declares, since a credential passed under an unknown name + is still a credential. + +- PLC4Go's S7 driver reads the rack and slot as "cotp.local-rack", + "cotp.local-slot", "cotp.remote-rack" and "cotp.remote-slot". It read + them unprefixed, while PLC4J declares them on the COTP transport's + configuration and every S7 example in the documentation spells them with + the prefix - so the documented connection string set nothing in PLC4Go + and said so nowhere. The unprefixed names are now reported as unknown. + +- Fixed PLC4Go logging connection strings verbatim. A password in a Go + connection string reached the log in clear at debug level, at twenty + call sites across the driver manager and the connection cache. They are + redacted now, along with credentials in a URI's userinfo. The parsed + URL and the connection container render redacted too - both reached the + same log lines by another route, so a redacted field sat beside the + credential it was hiding. + +- PLC4Go addresses a transport's connection-string options under the + transport's own code, as PLC4J does and as the documentation has always + said: "tcp.connect-timeout-ms", "serial.baud-rate", "udp.so-reuse", + "pcap.speed-factor". They were read unprefixed, so every documented + transport setting was ignored in PLC4Go and left at its default. The + unprefixed names are now reported as unknown rather than silently + doing nothing. Options a driver injects into the map itself + ("defaultTcpPort") are not addressed by anyone and keep their bare + names. + +- PLC4Go's OPC UA driver reads the parameter names PLC4J declares and the + documentation lists - "tls.keystore", "tls.keystore-password", + "security-policy", "allow-unverified-security-policies" - rather than + names derived from its own Go struct fields ("keyStoreFile", + "securityPolicy"). The documented connection string reached it as a + set of unknown options and was ignored. + +- A secret marking in PLC4Go applies whatever the field's type is. The + generator honoured "secret:\"true\"" only where it rendered a string, so + the tag on any other kind of field was accepted and silently did + nothing. The OPC UA key pair now carries the marking in both the + configuration and the secure channel. + +- Redaction decides from the parameter name the driver will read, not the + name as written: "?%70assword=hunter2" is the password parameter once + the query is decoded, and was previously logged in clear. A connection + string nested inside another (the PLC4X proxy driver's + "remote-connection-string") is redacted as a connection string in its + own right, so its credentials no longer travel through the outer one - + while which PLC the proxy talks to stays visible. + +- A BACnet/IP connection reported each unknown option once rather than + twice. Its options are parsed both by the driver, for the discovery + timeout, and by the connection; both reported, so one mistake read as + two. - The connection-creating methods of the API moved from "PlcConnectionManager" to a new "PlcConnectionFactory" interface, which the "PlcDriverManager" hands out via @@ -185,7 +335,7 @@ Incompatible changes Together this means a connection that names no certificate now fails where it previously came up unprotected. Name one with "server-certificate-file", or a trust store with - "trust-store-file"; or set "discovery=false" if the endpoint needs + "tls.trust-store"; or set "discovery=false" if the endpoint needs no discovery; or ask for "security-policy=NONE" to accept an unprotected channel as before. Note that a protected channel also needs a client key pair: @@ -219,8 +369,8 @@ Incompatible changes now fail. The new "allow-factory-default-certificate" parameter restores the old behaviour, with a warning; alternatively "server-certificate-file" names a single PEM certificate to - trust, or "trust-store-file" (with "trust-store-password" and - "trust-store-type") a key store of them, matching the names used + trust, or "tls.trust-store" (with "tls.trust-store-password" and + "tls.trust-store-type") a key store of them, matching the names used by the OPC UA driver and the TLS transport. "ignore-common-name" is also available if the certificate is trusted but names a different host. @@ -235,8 +385,8 @@ Incompatible changes than the address it is reached at will now fail where it previously succeeded. Two new parameters make the check usable where a device carries - its own certificate: "trust-store-file" (with - "trust-store-password" and "trust-store-type") names the + its own certificate: "tls.trust-store" (with + "tls.trust-store-password" and "tls.trust-store-type") names the certificates to trust instead of the public authorities the JVM ships with. Previously the only way past a private CA was "verify-ssl=false", which turns off both the chain check and diff --git a/THREAT-MODEL.md b/THREAT-MODEL.md index 5146e94bdf3..3263c678776 100644 --- a/THREAT-MODEL.md +++ b/THREAT-MODEL.md @@ -233,15 +233,15 @@ PLC4X has **two primary trust boundaries** plus one optional cryptographic one for OPC UA. A finding is in-model only when it cleanly maps to one of them. -| # | Transition | Authentication | Authorization | Notes | -|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| B1 | **Embedding application → PLC4X API surface** | trusted by construction | the API caller is trusted | URL string, `PlcAuthentication`, tag-address strings, write values *(documented)* | -| B2 | **PLC4X driver → remote PLC over the wire** (Modbus, S7 PUT/GET, BACnet/IP, IEC-60870-5-104, Profinet, CANopen, KNXnet/IP, C-Bus, DF1, AB-Ethernet, EtherNet/IP, UMAS, ADS/AMS without credentials) | **none — by protocol design** *(documented per protocol)* | none on the wire; whatever the embedding application enforces | The OT-network perimeter is the only security control. Wire bytes returned to the driver are **untrusted input crossing into the parser** *(inferred — §14 Q3)*. | -| B2-OPCUA | **PLC4X OPC UA driver → OPC UA server (encrypted policy)** | server cert verified against trust store *(when `trust-store-file` is set)*; client cert optional; username/password or token over the encrypted channel | server-side ACLs | Mutually authenticated (cert + cert) and encrypted at the higher security policies; **see §8 P1, §10 item 4** *(documented: `website/.../protocols/opcua.adoc`, `plc4j/drivers/opcua/.../security/`)* | -| B2-OPCUA-PERMISSIVE | **OPC UA driver in default (no `trust-store-file`) mode** | server certificates **are not validated** by default *(documented: `website/.../protocols/opcua.adoc` — "Unless explicitly disabled through configuration of `trust-store-file` all server certificates will be accepted without validation"; `plc4j/drivers/opcua/.../security/PermissiveCertificateVerifier.java`)* | n/a | This is the OPC UA driver's *default* behavior. See **§5a "insecure-default case"**. | -| B2-ADS | **ADS driver → Beckhoff TwinCAT (AMS route setup with credentials)** | username/password to the TwinCAT system for AMS-route setup, when a `PlcUsernamePasswordAuthentication` is supplied *(documented: `plc4j/drivers/ads/.../AdsProtocolLogic.java` lines 130–145)* | TwinCAT-side | The credentials are forwarded to the device; what the device does with them is outside PLC4X. ADS payload data itself is unauthenticated cleartext *(inferred — §14 Q9)*. | -| B3 | **PLC4X transport → host OS / NIC / serial port / libpcap** | OS-level | OS-level | Whatever the embedding process's UID, capabilities, and seccomp / AppArmor profile permit. The `raw-socket` transport needs `CAP_NET_RAW` on Linux *(inferred — §14 Q10)*. | -| B4 | **Code-generation runtime → operating system** at build time | n/a — only the developer runs this | n/a | Build-time only; not part of the deployed artifact (out per §3 item 6) | +| # | Transition | Authentication | Authorization | Notes | +|---------------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| B1 | **Embedding application → PLC4X API surface** | trusted by construction | the API caller is trusted | URL string, `PlcAuthentication`, tag-address strings, write values *(documented)* | +| B2 | **PLC4X driver → remote PLC over the wire** (Modbus, S7 PUT/GET, BACnet/IP, IEC-60870-5-104, Profinet, CANopen, KNXnet/IP, C-Bus, DF1, AB-Ethernet, EtherNet/IP, UMAS, ADS/AMS without credentials) | **none — by protocol design** *(documented per protocol)* | none on the wire; whatever the embedding application enforces | The OT-network perimeter is the only security control. Wire bytes returned to the driver are **untrusted input crossing into the parser** *(inferred — §14 Q3)*. | +| B2-OPCUA | **PLC4X OPC UA driver → OPC UA server (encrypted policy)** | server cert verified against trust store *(when `tls.trust-store` is set)*; client cert optional; username/password or token over the encrypted channel | server-side ACLs | Mutually authenticated (cert + cert) and encrypted at the higher security policies; **see §8 P1, §10 item 4** *(documented: `website/.../protocols/opcua.adoc`, `plc4j/drivers/opcua/.../security/`)* | +| B2-OPCUA-PERMISSIVE | **OPC UA driver in default (no `tls.trust-store`) mode** | server certificates **are not validated** by default *(documented: `website/.../protocols/opcua.adoc` — "Unless explicitly disabled through configuration of `tls.trust-store` all server certificates will be accepted without validation"; `plc4j/drivers/opcua/.../security/PermissiveCertificateVerifier.java`)* | n/a | This is the OPC UA driver's *default* behavior. See **§5a "insecure-default case"**. | +| B2-ADS | **ADS driver → Beckhoff TwinCAT (AMS route setup with credentials)** | username/password to the TwinCAT system for AMS-route setup, when a `PlcUsernamePasswordAuthentication` is supplied *(documented: `plc4j/drivers/ads/.../AdsProtocolLogic.java` lines 130–145)* | TwinCAT-side | The credentials are forwarded to the device; what the device does with them is outside PLC4X. ADS payload data itself is unauthenticated cleartext *(inferred — §14 Q9)*. | +| B3 | **PLC4X transport → host OS / NIC / serial port / libpcap** | OS-level | OS-level | Whatever the embedding process's UID, capabilities, and seccomp / AppArmor profile permit. The `raw-socket` transport needs `CAP_NET_RAW` on Linux *(inferred — §14 Q10)*. | +| B4 | **Code-generation runtime → operating system** at build time | n/a — only the developer runs this | n/a | Build-time only; not part of the deployed artifact (out per §3 item 6) | ### Reachability preconditions per family @@ -358,26 +358,26 @@ single global flag set. The security-relevant ones — those whose default value materially changes the security envelope — are collected here. -| Knob | Default | Maintainer stance | Effect | -|----------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| OPC UA `security-policy` | `NONE` *(documented: `plc4j/drivers/opcua/.../config/OpcuaConfiguration.java`)* | **maintainer ruling required** — is "no encryption" a supported production posture, or dev-default? *(inferred — §14 Q14)* | If `NONE`, the OPC UA channel runs unencrypted and unauthenticated; B2-OPCUA collapses to B2 (cleartext). | -| OPC UA `message-security` | `SIGN_ENCRYPT` *(documented)* | hardened default | When the security policy is not `NONE`, this forces sign-and-encrypt; flipping to `SIGN` (signed-cleartext) or `NONE` weakens the channel. | -| OPC UA `trust-store-file` | **unset** *(documented: `website/.../protocols/opcua.adoc` — "Unless explicitly disabled through configuration of `trust-store-file` all server certificates will be accepted without validation"; `plc4j/drivers/opcua/.../security/PermissiveCertificateVerifier.java`)* | **The OPC UA driver defaults to `PermissiveCertificateVerifier` — server certificates are accepted without validation**. This is **the single highest-priority maintainer ruling** in the document. *(inferred — §14 Q15)* | Without a trust store, an MITM attacker on the OT network can present any certificate and the driver will trust it. Even with the security policy at `Basic256Sha256` and `SIGN_ENCRYPT`, the encryption peer is unauthenticated. | -| OPC UA `key-store-file` / `key-store-password` | unset *(documented)* | operator must supply for mutual-TLS-like client auth | If unset and a security policy ≠ `NONE` is configured, the driver auto-generates a self-signed client certificate *(documented: `website/.../protocols/opcua.adoc`)*. Auto-generated certs are not recoverable across restarts; they cannot satisfy a peer that requires a known client identity. | -| OPC UA `discovery` | `true` *(documented)* | enabled by default; **the discovery phase is conducted with security policy `NONE`** *(documented: `OpcuaConfiguration.java`)* | An attacker on the path between the driver and the discovery endpoint sees / can rewrite the advertised endpoint, security policies, and server certificate before the driver picks one. | -| OPC UA `username` / `password` | unset *(documented)* | operator-supplied | Forwarded as the OPC UA `UserIdentityToken`; carried inside the secure channel when one exists, in cleartext otherwise. | -| OPC UA `channel-lifetime`, `session-timeout`, `negotiation-timeout`, `request-timeout` | 1 h / 2 min / 60 s / 30 s *(documented)* | reasonable defaults | DoS / timeout-tuning surface; not a security boundary. | -| ADS `PlcUsernamePasswordAuthentication` (when supplied) | unset (no AMS-route setup) | operator-supplied | When supplied, drives an AMS-route registration against the TwinCAT system using HTTP-style credentials *(documented: `plc4j/drivers/ads/.../AdsProtocolLogic.java`)*. The credentials are not protected by PLC4X on the wire; TwinCAT-side TLS is the device's responsibility. | -| BACnet/IP `ede-file-path` / `ede-directory-path` | unset | operator-supplied | If set, points at filesystem paths; standard file-permission rules apply. | -| KNX `.knxproj` parser | XXE / external-DTD / external-schema **disabled** *(documented: `EtsParser.java`)* | hardened — this is the safe defaults case | Reports of the shape "XXE in `.knxproj` parsing" are `KNOWN-NON-FINDING`. | -| S7 `controller-type` | unset (auto-discover via SZL) | operator-supplied for Siemens LOGO compatibility | Functional knob; not a security boundary. | -| Modbus connection options (`unit-id`, byte order) | per spec | functional knobs | not security boundaries. | -| `plc4c/`, `plc4py/`, `plc4net/` builds | **README declares these "not ready for usage" (with `plc4net` "abandoned")** *(documented: `README.md`)* | **OUT-OF-MODEL** for §8 properties; bugs reported against C / Python / .NET bindings should be triaged as code-quality / completeness, not as supported-product vulnerabilities until the maintainer reclassifies them | A finding in `plc4c/` is `OUT-OF-MODEL: unsupported-component` until the README line changes. | +| Knob | Default | Maintainer stance | Effect | +|----------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| OPC UA `security-policy` | `NONE` *(documented: `plc4j/drivers/opcua/.../config/OpcuaConfiguration.java`)* | **maintainer ruling required** — is "no encryption" a supported production posture, or dev-default? *(inferred — §14 Q14)* | If `NONE`, the OPC UA channel runs unencrypted and unauthenticated; B2-OPCUA collapses to B2 (cleartext). | +| OPC UA `message-security` | `SIGN_ENCRYPT` *(documented)* | hardened default | When the security policy is not `NONE`, this forces sign-and-encrypt; flipping to `SIGN` (signed-cleartext) or `NONE` weakens the channel. | +| OPC UA `tls.trust-store` | **unset** *(documented: `website/.../protocols/opcua.adoc` — "Unless explicitly disabled through configuration of `tls.trust-store` all server certificates will be accepted without validation"; `plc4j/drivers/opcua/.../security/PermissiveCertificateVerifier.java`)* | **The OPC UA driver defaults to `PermissiveCertificateVerifier` — server certificates are accepted without validation**. This is **the single highest-priority maintainer ruling** in the document. *(inferred — §14 Q15)* | Without a trust store, an MITM attacker on the OT network can present any certificate and the driver will trust it. Even with the security policy at `Basic256Sha256` and `SIGN_ENCRYPT`, the encryption peer is unauthenticated. | +| OPC UA `key-store-file` / `key-store-password` | unset *(documented)* | operator must supply for mutual-TLS-like client auth | If unset and a security policy ≠ `NONE` is configured, the driver auto-generates a self-signed client certificate *(documented: `website/.../protocols/opcua.adoc`)*. Auto-generated certs are not recoverable across restarts; they cannot satisfy a peer that requires a known client identity. | +| OPC UA `discovery` | `true` *(documented)* | enabled by default; **the discovery phase is conducted with security policy `NONE`** *(documented: `OpcuaConfiguration.java`)* | An attacker on the path between the driver and the discovery endpoint sees / can rewrite the advertised endpoint, security policies, and server certificate before the driver picks one. | +| OPC UA `username` / `password` | unset *(documented)* | operator-supplied | Forwarded as the OPC UA `UserIdentityToken`; carried inside the secure channel when one exists, in cleartext otherwise. | +| OPC UA `channel-lifetime`, `session-timeout`, `negotiation-timeout`, `request-timeout` | 1 h / 2 min / 60 s / 30 s *(documented)* | reasonable defaults | DoS / timeout-tuning surface; not a security boundary. | +| ADS `PlcUsernamePasswordAuthentication` (when supplied) | unset (no AMS-route setup) | operator-supplied | When supplied, drives an AMS-route registration against the TwinCAT system using HTTP-style credentials *(documented: `plc4j/drivers/ads/.../AdsProtocolLogic.java`)*. The credentials are not protected by PLC4X on the wire; TwinCAT-side TLS is the device's responsibility. | +| BACnet/IP `ede-file-path` / `ede-directory-path` | unset | operator-supplied | If set, points at filesystem paths; standard file-permission rules apply. | +| KNX `.knxproj` parser | XXE / external-DTD / external-schema **disabled** *(documented: `EtsParser.java`)* | hardened — this is the safe defaults case | Reports of the shape "XXE in `.knxproj` parsing" are `KNOWN-NON-FINDING`. | +| S7 `controller-type` | unset (auto-discover via SZL) | operator-supplied for Siemens LOGO compatibility | Functional knob; not a security boundary. | +| Modbus connection options (`unit-id`, byte order) | per spec | functional knobs | not security boundaries. | +| `plc4c/`, `plc4py/`, `plc4net/` builds | **README declares these "not ready for usage" (with `plc4net` "abandoned")** *(documented: `README.md`)* | **OUT-OF-MODEL** for §8 properties; bugs reported against C / Python / .NET bindings should be triaged as code-quality / completeness, not as supported-product vulnerabilities until the maintainer reclassifies them | A finding in `plc4c/` is `OUT-OF-MODEL: unsupported-component` until the README line changes. | ### The insecure-default case (OPC UA) The OPC UA driver's **defaults are dev/lab-grade, not production-grade**: -`security-policy=NONE`, `trust-store-file` unset, `discovery=true` over an +`security-policy=NONE`, `tls.trust-store` unset, `discovery=true` over an unencrypted channel. Each of these defaults voids a §8 property the driver could otherwise provide. The §14 Q14, Q15, Q16 questions ask the maintainer to choose, per knob, between: @@ -416,7 +416,7 @@ hypothesis (b)** and will need adjustment if the maintainer chooses | Cleartext OT-protocol response (Modbus, S7, BACnet/IP, IEC-60870-104, Profinet, CANopen, KNXnet/IP, C-Bus, DF1, AB-Ethernet, ADS payload, EtherNet/IP, UMAS) | every byte of every response frame | **yes** — anyone on the OT network with reachability can spoof | **memory safety, bounded allocation, no infinite loop, no unbounded recursion** on malformed input — but **not** authenticity, integrity, or any payload-semantic guarantee *(inferred — §14 Q11)* | | OPC UA wire frames inside the secure channel | every byte | **yes** — but signed/encrypted by the negotiated policy when policy ≠ `NONE` | as above plus: correct verification of signature and MAC under the negotiated policy; correct decryption; correct chunk reassembly *(documented + inferred — §14 Q17)* | | OPC UA wire frames during discovery (`security-policy=NONE` phase) | every byte | **yes** | memory safety on malformed responses; **the discovery handshake itself is not authenticated** *(documented)* | -| OPC UA server certificate (presented during handshake) | full DER bytes | **yes** | when `trust-store-file` is set: X.509 chain validation per JCE rules; when not set (default): **none** *(documented: §5a)* | +| OPC UA server certificate (presented during handshake) | full DER bytes | **yes** | when `tls.trust-store` is set: X.509 chain validation per JCE rules; when not set (default): **none** *(documented: §5a)* | | Serial-line frames | every byte | yes if the serial channel is attacker-reachable | memory safety on malformed framing *(inferred — §14 Q11)* | | libpcap capture / replay frames | every byte | yes if the capture file is attacker-controlled | the pcap-replay transport is a **dev/test tool**; if it's running in production, the integrator has put it there | | ETS `.knxproj` XML | as XML | yes if the file is attacker-supplied | XXE disabled *(documented)*; ZIP slip protection — *(inferred — §14 Q18)* | @@ -483,7 +483,7 @@ of the security work is delegated to the integrator (§10). `Aes256_Sha256_RsaPss` *(documented: `plc4j/drivers/opcua/.../security/SecurityPolicy.java`)*, AND `message-security` is `SIGN` or `SIGN_ENCRYPT` *(documented)*, AND - `trust-store-file` is set to a trust store containing the expected + `tls.trust-store` is set to a trust store containing the expected server certificate (or its issuer) *(documented: `website/.../protocols/opcua.adoc`)*. - **Violation symptom**: wire bytes between the driver and the server that an on-path attacker can decrypt (without the configured key) or @@ -496,7 +496,7 @@ of the security work is delegated to the integrator (§10). ### P2 — OPC UA server-certificate authentication, when a trust store is configured -- **Condition**: `trust-store-file` is set; the trust store contains +- **Condition**: `tls.trust-store` is set; the trust store contains the expected certificate or a chain root *(documented: `website/.../protocols/opcua.adoc`)*. - **Violation symptom**: the OPC UA driver completes a handshake with a @@ -689,7 +689,7 @@ important one for an integrator.** `KNOWN-NON-FINDING`. - **OPC UA discovery-channel MITM.** Out of model per §9 false-friend item 2. -- **OPC UA server-certificate substitution when `trust-store-file` is +- **OPC UA server-certificate substitution when `tls.trust-store` is unset (the default).** **Pending maintainer ruling** (§14 Q15). - **DoS via unbounded-tag-list `read` / `subscribe` requests.** Out of model per §9 "No DoS protection at the API surface". @@ -716,7 +716,7 @@ The embedding application / integrator deploying PLC4X in production such a tunnel. *(integrator)* 3. **For OPC UA in production: set `security-policy` to `Basic256Sha256` or stronger AND `message-security` to - `SIGN_ENCRYPT` AND configure a `trust-store-file` that contains + `SIGN_ENCRYPT` AND configure a `tls.trust-store` that contains only the expected server certificate or its issuing CA.** The default of "`NONE`, no trust store" is **not** the production posture *(inferred — §14 Q14, Q15)*. @@ -765,7 +765,7 @@ The embedding application / integrator deploying PLC4X in production - **Running OPC UA with `security-policy=NONE` in production.** The default. → §9 false-friend item 1. - **Running OPC UA with a configured security policy but no - `trust-store-file`.** The driver uses `PermissiveCertificateVerifier` + `tls.trust-store`.** The driver uses `PermissiveCertificateVerifier` by default — every server certificate is accepted. An MITM gets the encryption key. → §5a. - **Treating `discovery=true` as part of the security boundary.** @@ -843,7 +843,7 @@ every wire-format property of every driver.** - **"OPC UA driver accepts any server certificate."** True by default (`PermissiveCertificateVerifier`); the §10 item 3 contract requires - the operator to set `trust-store-file`. **Maintainer ruling (chrisdutz, + the operator to set `tls.trust-store`. **Maintainer ruling (chrisdutz, §14 Q15):** this default "should be changed and reported" — it is **not** the supported posture, so a report is **`VALID`** (a gap the PMC intends to fix toward secure-by-default), not @@ -912,7 +912,7 @@ Revise this document when any of the following lands: - A new transport gains a listening-socket capability (would convert PLC4X from a pure client to a client/server). - A change in the default value of any §5a knob — especially OPC UA - `security-policy`, `trust-store-file`, or `message-security`. + `security-policy`, `tls.trust-store`, or `message-security`. - `plc4c/`, `plc4py/`, or `plc4net/` has its "not ready for usage" README flag removed — those move from §3 item 6 to in-model. - The boundary with `plc4x-extras` shifts (e.g. an integration is @@ -1008,7 +1008,7 @@ the secure path the new default; the current `NONE` default is dev/lab convenience, **not** a supported production posture. *(maps to §5a, §10, §11a, §13)* **Q15.** OPC UA default `PermissiveCertificateVerifier` — the -single highest-priority question. When `trust-store-file` is unset, +single highest-priority question. When `tls.trust-store` is unset, the driver accepts every server certificate. Is "OPC UA driver accepts attacker-presented certificate" `VALID` (stance (a)) or `OUT-OF-MODEL: non-default-build` (stance (b))? **Answered (maintainer — chrisdutz):** the permissive default "should be @@ -1186,7 +1186,7 @@ security-policy artefacts are: |---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|-------------------------------------|-----------------------------------------------------------| | `README.md` ("`plc4c/`, `plc4py/` not ready for usage; `plc4net/` abandoned") | scope carve-out | §3 item 6, §5a | | `README.md` ("The Industrial IoT adapter … client-side library across multiple PLC protocols") | scope framing | §2 | -| `website/.../protocols/opcua.adoc` ("Unless explicitly disabled through configuration of `trust-store-file` all server certificates will be accepted without validation") | default-permissive verifier | §4 B2-OPCUA-PERMISSIVE, §5a, §9 false-friend item 1, §11a | +| `website/.../protocols/opcua.adoc` ("Unless explicitly disabled through configuration of `tls.trust-store` all server certificates will be accepted without validation") | default-permissive verifier | §4 B2-OPCUA-PERMISSIVE, §5a, §9 false-friend item 1, §11a | | `website/.../protocols/opcua.adoc` ("discovery phase is always conducted using `NONE` security policy" — paraphrased from `OpcuaConfiguration.java` discovery doc) | discovery unencrypted | §4 B2-OPCUA-PERMISSIVE, §9 false-friend item 2 | | `website/.../protocols/opcua.adoc` ("`message-security` … `SIGN_ENCRYPT` … high security settings and full encryption") | secure-channel message security | §5a, §8 P1 | | `website/.../protocols/opcua.adoc` ("There is transport level certificate which can be provided though keystore options, but there is also a X509 Certificate which can be used for authentication (currently unsupported by PLC4X)") | client-X509-auth not implemented | §11a, §14 Q14 | diff --git a/plc4go/assets/testing/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml b/plc4go/assets/testing/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml index 1a1ce259898..4aeab2c3ddd 100644 --- a/plc4go/assets/testing/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml +++ b/plc4go/assets/testing/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml @@ -32,7 +32,7 @@ 1 - request-timeout + request-timeout-ms 5000 diff --git a/plc4go/internal/abeth/Configuration.go b/plc4go/internal/abeth/Configuration.go index 16c8c84c294..a5f59e19aa4 100644 --- a/plc4go/internal/abeth/Configuration.go +++ b/plc4go/internal/abeth/Configuration.go @@ -27,6 +27,7 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) // Configuration is what an ab-eth connection string can say about the connection. Ported from @@ -42,7 +43,7 @@ type Configuration struct { const ( // defaultStation is plc4j's @IntDefaultValue(0) for the station option. defaultStation = uint8(0) - // defaultRequestTimeout is plc4j's @IntDefaultValue(10_000) for the request-timeout option. + // defaultRequestTimeout is plc4j's @IntDefaultValue(10_000) for the request-timeout-ms option. defaultRequestTimeout = 10 * time.Second ) @@ -57,9 +58,14 @@ func DefaultConfiguration() Configuration { // ParseFromOptions reads the connection options out of a parsed connection string. The timeout is // spelled in milliseconds, the way plc4j's @IntDefaultValue(10_000) does. func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, connectionOptions) + defer reader.ReportUnknown("ab-eth") + configuration := DefaultConfiguration() - if stationString := getFromOptions(localLog, connectionOptions, "station"); stationString != "" { + if stationString := reader.Get("station"); stationString != "" { // The station is the DF1 destination address, which is a single byte on the wire. parsedInt, err := strconv.ParseUint(stationString, 10, 8) if err != nil { @@ -68,13 +74,13 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st configuration.station = uint8(parsedInt) } - if requestTimeoutString := getFromOptions(localLog, connectionOptions, "request-timeout"); requestTimeoutString != "" { + if requestTimeoutString := reader.Get("request-timeout-ms"); requestTimeoutString != "" { parsedInt, err := strconv.ParseUint(requestTimeoutString, 10, 32) if err != nil { - return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout %s", requestTimeoutString) + return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout-ms %s", requestTimeoutString) } if parsedInt == 0 { - return Configuration{}, errors.New("request-timeout must be greater than zero") + return Configuration{}, errors.New("request-timeout-ms must be greater than zero") } configuration.requestTimeout = time.Duration(parsedInt) * time.Millisecond } @@ -85,17 +91,3 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st func (c Configuration) String() string { return fmt.Sprintf("abeth.Configuration{station: %d, requestTimeout: %s}", c.station, c.requestTimeout) } - -// getFromOptions plucks a single-valued option out of the parsed connection string. -func getFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string, key string) string { - if optionValues, ok := connectionOptions[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Option must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/abeth/Configuration_test.go b/plc4go/internal/abeth/Configuration_test.go index aa41990156f..ebeaa421a12 100644 --- a/plc4go/internal/abeth/Configuration_test.go +++ b/plc4go/internal/abeth/Configuration_test.go @@ -48,12 +48,12 @@ func TestParseFromOptions(t *testing.T) { }, { name: "a request timeout in milliseconds", - options: map[string][]string{"request-timeout": {"2500"}}, + options: map[string][]string{"request-timeout-ms": {"2500"}}, want: Configuration{station: 0, requestTimeout: 2500 * time.Millisecond}, }, { name: "both options", - options: map[string][]string{"station": {"255"}, "request-timeout": {"1"}}, + options: map[string][]string{"station": {"255"}, "request-timeout-ms": {"1"}}, want: Configuration{station: 255, requestTimeout: time.Millisecond}, }, { @@ -74,12 +74,12 @@ func TestParseFromOptions(t *testing.T) { }, { name: "a zero request timeout", - options: map[string][]string{"request-timeout": {"0"}}, + options: map[string][]string{"request-timeout-ms": {"0"}}, wantErr: true, }, { name: "a non-numeric request timeout", - options: map[string][]string{"request-timeout": {"soon"}}, + options: map[string][]string{"request-timeout-ms": {"soon"}}, wantErr: true, }, } diff --git a/plc4go/internal/bacnetip/Configuration.go b/plc4go/internal/bacnetip/Configuration.go index a5c94804455..40105f77286 100644 --- a/plc4go/internal/bacnetip/Configuration.go +++ b/plc4go/internal/bacnetip/Configuration.go @@ -22,11 +22,11 @@ package bacnetip import ( "reflect" "strconv" - "strings" "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) // Configuration captures driver-level options parsed from the connection URL. @@ -125,12 +125,34 @@ type Configuration struct { // are matched case-insensitively so users can write "localDeviceId", "LocalDeviceId", // or "localdeviceid" interchangeably in the connection string. func ParseFromOptions(log zerolog.Logger, optionsMap map[string][]string) (Configuration, error) { + return parseFromOptions(log, optionsMap, true) +} + +// ParseFromOptionsQuietly is ParseFromOptions without the report of options nothing read. +// +// This driver parses the same options twice: the driver reads them early to give the discoverer +// its timeout, and the connection reads them again when it is built. Reporting from both printed +// every warning twice, so one bad option told the operator about itself twice - which reads like +// two problems. The connection's parse is the one that reports, being the one whose result the +// connection actually uses. +func ParseFromOptionsQuietly(log zerolog.Logger, optionsMap map[string][]string) (Configuration, error) { + return parseFromOptions(log, optionsMap, false) +} + +func parseFromOptions(log zerolog.Logger, optionsMap map[string][]string, report bool) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(log, optionsMap).CaseInsensitive() + if report { + defer reader.ReportUnknown("bacnet-ip") + } + configuration := createDefaultConfiguration() rv := reflect.ValueOf(&configuration).Elem() for i := 0; i < rv.NumField(); i++ { field := rv.Type().Field(i) key := field.Name - optionValue := getFromOptions(log, optionsMap, key) + optionValue := reader.Get(key) if optionValue == "" { continue } @@ -175,23 +197,3 @@ func createDefaultConfiguration() Configuration { DiscoveryTimeoutSeconds: 5, } } - -// getFromOptions returns the first value associated with key, matching the -// optionsMap key case-insensitively (BACnet field names are CamelCase; users -// commonly type lowercase in URLs). -func getFromOptions(localLog zerolog.Logger, optionsMap map[string][]string, key string) string { - target := strings.ToLower(key) - for k, optionValues := range optionsMap { - if strings.ToLower(k) != target { - continue - } - if len(optionValues) == 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", k).Msg("Options key must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/bacnetip/Configuration_test.go b/plc4go/internal/bacnetip/Configuration_test.go index 07b82d07d5e..ea1c6940371 100644 --- a/plc4go/internal/bacnetip/Configuration_test.go +++ b/plc4go/internal/bacnetip/Configuration_test.go @@ -108,8 +108,6 @@ func TestCreateDefaultConfiguration(t *testing.T) { assert.Equal(t, uint32(5), cfg.DiscoveryTimeoutSeconds) } -func TestGetFromOptions(t *testing.T) { - log := testutils.ProduceTestingLogger(t) - assert.Empty(t, getFromOptions(log, map[string][]string{}, "missing")) - assert.Equal(t, "first", getFromOptions(log, map[string][]string{"present": {"first", "second"}}, "present")) -} +// The per-driver getFromOptions this used to cover is gone: every driver now reads options +// through spi/options.OptionReader, so the same behaviour - an absent option is empty, a repeated +// one yields the first value and warns - is covered once, in OptionReader_test.go. diff --git a/plc4go/internal/bacnetip/Driver.go b/plc4go/internal/bacnetip/Driver.go index 9c507f7f5f5..db17e778836 100644 --- a/plc4go/internal/bacnetip/Driver.go +++ b/plc4go/internal/bacnetip/Driver.go @@ -134,7 +134,7 @@ func (d *Driver) GetConnection(ctx context.Context, transportUrl url.URL, transp // Parse Configuration early so we can propagate the discovery timeout to // the Discoverer for the lifetime of this Driver instance. (Discovery is a // driver-level call, but the timeout is naturally part of Configuration.) - if cfg, cfgErr := ParseFromOptions(connectionLog, driverOptions); cfgErr == nil { + if cfg, cfgErr := ParseFromOptionsQuietly(connectionLog, driverOptions); cfgErr == nil { d.discoverer.SetDiscoveryTimeout(time.Duration(cfg.DiscoveryTimeoutSeconds) * time.Second) } diff --git a/plc4go/internal/cbus/Configuration.go b/plc4go/internal/cbus/Configuration.go index 00e094106b2..18e9550d436 100644 --- a/plc4go/internal/cbus/Configuration.go +++ b/plc4go/internal/cbus/Configuration.go @@ -28,6 +28,7 @@ import ( "golang.org/x/text/language" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) //go:generate go tool plc4xGenerator -type=Configuration @@ -48,13 +49,18 @@ type Configuration struct { } func ParseFromOptions(log zerolog.Logger, options map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(log, options) + defer reader.ReportUnknown("c-bus") + titleOptions(options) configuration := createDefaultConfiguration() reflectConfiguration := reflect.ValueOf(&configuration).Elem() for i := 0; i < reflectConfiguration.NumField(); i++ { field := reflectConfiguration.Type().Field(i) key := field.Name - if optionValue := getFromOptions(log, options, key); optionValue != "" { + if optionValue := reader.Get(key); optionValue != "" { switch field.Type.Kind() { case reflect.Uint8: parseUint, err := strconv.ParseUint(optionValue, 0, 8) @@ -97,16 +103,3 @@ func createDefaultConfiguration() Configuration { MonitoredApplication2: 0xFF, } } - -func getFromOptions(localLog zerolog.Logger, options map[string][]string, key string) string { - if optionValues, ok := options[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Options key must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/cbus/Configuration_test.go b/plc4go/internal/cbus/Configuration_test.go index 1a84f095bcd..0b6a6240602 100644 --- a/plc4go/internal/cbus/Configuration_test.go +++ b/plc4go/internal/cbus/Configuration_test.go @@ -20,11 +20,14 @@ package cbus import ( + "bytes" "fmt" "testing" + "github.com/rs/zerolog" "github.com/stretchr/testify/assert" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" "github.com/apache/plc4x/plc4go/spi/testutils" ) @@ -143,6 +146,18 @@ func TestParseFromOptions(t *testing.T) { } } +// titleOptions title-cases every key in place, marker included, so the exact-match skip in +// ReportUnknown is not enough here: the title-cased duplicate must not surface as unknown. +func TestParseFromOptions_saysNothingAboutTheActiveTransportMarker(t *testing.T) { + var logged bytes.Buffer + log := zerolog.New(&logged) + + _, err := ParseFromOptions(log, map[string][]string{spiOptions.ActiveTransportOption: {"tcp"}}) + + assert.NoError(t, err) + assert.Empty(t, logged.String()) +} + func Test_createDefaultConfiguration(t *testing.T) { tests := []struct { name string @@ -171,35 +186,5 @@ func Test_createDefaultConfiguration(t *testing.T) { } } -func Test_getFromOptions(t *testing.T) { - type args struct { - options map[string][]string - key string - } - tests := []struct { - name string - args args - want string - }{ - { - name: "key not found", - args: args{ - options: map[string][]string{}, - key: "testKey", - }, - }, - { - name: "key found", - args: args{ - options: map[string][]string{"testKey": {"asd", "asd"}}, - key: "testKey", - }, - want: "asd", // note: multi keys not supported yet, so first one is returned - }, - } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - assert.Equalf(t, tt.want, getFromOptions(testutils.ProduceTestingLogger(t), tt.args.options, tt.args.key), "getFromOptions(%v, %v)", tt.args.options, tt.args.key) - }) - } -} +// The per-driver getFromOptions this used to cover is gone: every driver now reads options +// through spi/options.OptionReader, and its behaviour is covered once, in OptionReader_test.go. diff --git a/plc4go/internal/eip/Configuration.go b/plc4go/internal/eip/Configuration.go index c1f80de9bff..9423bdae3f3 100644 --- a/plc4go/internal/eip/Configuration.go +++ b/plc4go/internal/eip/Configuration.go @@ -25,6 +25,7 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) type Configuration struct { @@ -37,41 +38,46 @@ type Configuration struct { } func ParseFromOptions(localLogger zerolog.Logger, options map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLogger, options) + defer reader.ReportUnknown("eip") + configuration := Configuration{ backplane: 1, slot: 0, bigEndian: true, } - if localRackString := getFromOptions(localLogger, options, "backplane"); localRackString != "" { + if localRackString := reader.Get("backplane"); localRackString != "" { parsedBackplane, err := strconv.ParseInt(localRackString, 10, 8) if err != nil { return Configuration{}, errors.Wrap(err, "Error parsing backplane") } configuration.backplane = int8(parsedBackplane) } - if localSlotString := getFromOptions(localLogger, options, "slot"); localSlotString != "" { + if localSlotString := reader.Get("slot"); localSlotString != "" { parsedSlot, err := strconv.ParseInt(localSlotString, 10, 8) if err != nil { return Configuration{}, errors.Wrap(err, "Error parsing slot") } configuration.slot = int8(parsedSlot) } - if bigEndianString := getFromOptionsAliases(localLogger, options, "bigEndian", "big-endian"); bigEndianString != "" { + if bigEndianString := getFromOptionsAliases(reader, "bigEndian", "big-endian"); bigEndianString != "" { parsedBigEndian, err := strconv.ParseBool(bigEndianString) if err != nil { return Configuration{}, errors.Wrap(err, "Error parsing bigEndian") } configuration.bigEndian = parsedBigEndian } - if forceUnconnectedString := getFromOptionsAliases(localLogger, options, "forceUnconnectedOperation", "force-unconnected-operation"); forceUnconnectedString != "" { + if forceUnconnectedString := getFromOptionsAliases(reader, "forceUnconnectedOperation", "force-unconnected-operation"); forceUnconnectedString != "" { parsedForceUnconnected, err := strconv.ParseBool(forceUnconnectedString) if err != nil { return Configuration{}, errors.Wrap(err, "Error parsing forceUnconnectedOperation") } configuration.forceUnconnectedOperation = parsedForceUnconnected } - configuration.communicationPath = getFromOptionsAliases(localLogger, options, "communicationPath", "communication-path") - if serialNumberString := getFromOptionsAliases(localLogger, options, "connectionSerialNumber", "connection-serial-number"); serialNumberString != "" { + configuration.communicationPath = getFromOptionsAliases(reader, "communicationPath", "communication-path") + if serialNumberString := getFromOptionsAliases(reader, "connectionSerialNumber", "connection-serial-number"); serialNumberString != "" { parsedSerialNumber, err := strconv.ParseUint(serialNumberString, 10, 16) if err != nil { return Configuration{}, errors.Wrap(err, "Error parsing connectionSerialNumber") @@ -81,22 +87,9 @@ func ParseFromOptions(localLogger zerolog.Logger, options map[string][]string) ( return configuration, nil } -func getFromOptions(localLogger zerolog.Logger, options map[string][]string, key string) string { - if optionValues, ok := options[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLogger.Warn().Str("key", key).Msg("Options %s must be unique") - } - return optionValues[0] - } - return "" -} - -func getFromOptionsAliases(localLogger zerolog.Logger, options map[string][]string, keys ...string) string { +func getFromOptionsAliases(reader *spiOptions.OptionReader, keys ...string) string { for _, key := range keys { - if value := getFromOptions(localLogger, options, key); value != "" { + if value := reader.Get(key); value != "" { return value } } diff --git a/plc4go/internal/firmata/Configuration.go b/plc4go/internal/firmata/Configuration.go index fa7322bc5a0..01765bc99e4 100644 --- a/plc4go/internal/firmata/Configuration.go +++ b/plc4go/internal/firmata/Configuration.go @@ -26,6 +26,7 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) // Configuration is what a firmata connection string can say about the connection as a whole. @@ -39,7 +40,7 @@ type Configuration struct { } const ( - // defaultRequestTimeout is plc4j's FirmataConfiguration request-timeout default of ten + // defaultRequestTimeout is plc4j's FirmataConfiguration request-timeout-ms default of ten // seconds. A board that has just been reset needs a moment before it reports its firmware. defaultRequestTimeout = 10 * time.Second ) @@ -54,32 +55,23 @@ func DefaultConfiguration() Configuration { // ParseFromOptions reads the connection options out of a parsed connection string. The timeout is // spelled in milliseconds, the way plc4j's @IntDefaultValue(10_000) does. func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, connectionOptions) + defer reader.ReportUnknown("firmata") + configuration := DefaultConfiguration() - if requestTimeoutString := getFromOptions(localLog, connectionOptions, "request-timeout"); requestTimeoutString != "" { + if requestTimeoutString := reader.Get("request-timeout-ms"); requestTimeoutString != "" { parsedInt, err := strconv.ParseUint(requestTimeoutString, 10, 32) if err != nil { - return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout %s", requestTimeoutString) + return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout-ms %s", requestTimeoutString) } if parsedInt == 0 { - return Configuration{}, errors.New("request-timeout must be greater than zero") + return Configuration{}, errors.New("request-timeout-ms must be greater than zero") } configuration.requestTimeout = time.Duration(parsedInt) * time.Millisecond } return configuration, nil } - -// getFromOptions plucks a single-valued option out of the parsed connection string. -func getFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string, key string) string { - if optionValues, ok := connectionOptions[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Option must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/firmata/Configuration_test.go b/plc4go/internal/firmata/Configuration_test.go index 6dd0da7fcc1..5b65730e480 100644 --- a/plc4go/internal/firmata/Configuration_test.go +++ b/plc4go/internal/firmata/Configuration_test.go @@ -43,32 +43,32 @@ func TestParseFromOptions(t *testing.T) { { // plc4j spells the timeout in milliseconds (@IntDefaultValue(10_000)). name: "a request timeout in milliseconds", - connectionOptions: map[string][]string{"request-timeout": {"250"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"250"}}, want: 250 * time.Millisecond, }, { name: "an empty option falls back to the default", - connectionOptions: map[string][]string{"request-timeout": {}}, + connectionOptions: map[string][]string{"request-timeout-ms": {}}, want: defaultRequestTimeout, }, { name: "the first of several values wins", - connectionOptions: map[string][]string{"request-timeout": {"250", "500"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"250", "500"}}, want: 250 * time.Millisecond, }, { name: "a timeout which isn't a number", - connectionOptions: map[string][]string{"request-timeout": {"soon"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"soon"}}, wantErr: true, }, { name: "a timeout of zero would never wait", - connectionOptions: map[string][]string{"request-timeout": {"0"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"0"}}, wantErr: true, }, { name: "a negative timeout", - connectionOptions: map[string][]string{"request-timeout": {"-1"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"-1"}}, wantErr: true, }, } diff --git a/plc4go/internal/firmata/Driver_test.go b/plc4go/internal/firmata/Driver_test.go index 93dd5a7ca43..bd858c24baf 100644 --- a/plc4go/internal/firmata/Driver_test.go +++ b/plc4go/internal/firmata/Driver_test.go @@ -91,7 +91,7 @@ func TestDriver_GetConnectionRejectsABadConfiguration(t *testing.T) { t.Context(), url.URL{Scheme: "test"}, map[string]transports.Transport{"test": test.NewTransport()}, - map[string][]string{"request-timeout": {"not a number"}}, + map[string][]string{"request-timeout-ms": {"not a number"}}, ) assert.Error(t, err) assert.Nil(t, connection) diff --git a/plc4go/internal/iec608705104/Configuration.go b/plc4go/internal/iec608705104/Configuration.go index 5e482d646be..2c2b689ad13 100644 --- a/plc4go/internal/iec608705104/Configuration.go +++ b/plc4go/internal/iec608705104/Configuration.go @@ -26,10 +26,11 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) const ( - // defaultRequestTimeout is plc4j's Iec608705014Configuration request-timeout default of four + // defaultRequestTimeout is plc4j's Iec608705014Configuration request-timeout-ms default of four // seconds. It bounds the two handshake round trips (test frame and start-data-transfer) which // are the only request/response interactions the protocol has - everything after them is // unsolicited. @@ -45,7 +46,7 @@ const ( ) // Configuration is what an IEC 60870-5-104 connection string can say about the connection as a -// whole. plc4j's Iec608705014Configuration knows only request-timeout; the acknowledgement window +// whole. plc4j's Iec608705014Configuration knows only request-timeout-ms; the acknowledgement window // is added here because it is a real protocol parameter ('w') that plc4j buried in a constant, and // a station which insists on a smaller window otherwise drops the connection. type Configuration struct { @@ -68,20 +69,25 @@ func DefaultConfiguration() Configuration { // ParseFromOptions reads the connection options out of a parsed connection string. The timeout is // spelled in milliseconds, the way plc4j's @IntDefaultValue(4000) does. func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, connectionOptions) + defer reader.ReportUnknown("iec-60870-5-104") + configuration := DefaultConfiguration() - if requestTimeoutString := getFromOptions(localLog, connectionOptions, "request-timeout"); requestTimeoutString != "" { + if requestTimeoutString := reader.Get("request-timeout-ms"); requestTimeoutString != "" { parsedInt, err := strconv.ParseUint(requestTimeoutString, 10, 32) if err != nil { - return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout %s", requestTimeoutString) + return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout-ms %s", requestTimeoutString) } if parsedInt == 0 { - return Configuration{}, errors.New("request-timeout must be greater than zero") + return Configuration{}, errors.New("request-timeout-ms must be greater than zero") } configuration.requestTimeout = time.Duration(parsedInt) * time.Millisecond } - if ackThresholdString := getFromOptions(localLog, connectionOptions, "ack-threshold"); ackThresholdString != "" { + if ackThresholdString := reader.Get("ack-threshold"); ackThresholdString != "" { parsedInt, err := strconv.ParseUint(ackThresholdString, 10, 32) if err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing ack-threshold %s", ackThresholdString) @@ -97,17 +103,3 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st return configuration, nil } - -// getFromOptions plucks a single-valued option out of the parsed connection string. -func getFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string, key string) string { - if optionValues, ok := connectionOptions[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Option must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/iec608705104/Configuration_test.go b/plc4go/internal/iec608705104/Configuration_test.go index ba256118104..cbb9961b136 100644 --- a/plc4go/internal/iec608705104/Configuration_test.go +++ b/plc4go/internal/iec608705104/Configuration_test.go @@ -42,7 +42,7 @@ func TestParseFromOptions(t *testing.T) { }, { name: "a request timeout in milliseconds", - connectionOptions: map[string][]string{"request-timeout": {"1500"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"1500"}}, want: Configuration{requestTimeout: 1500 * time.Millisecond, ackThreshold: 8}, }, { @@ -65,8 +65,8 @@ func TestParseFromOptions(t *testing.T) { connectionOptions: map[string][]string{"ack-threshold": {}}, want: Configuration{requestTimeout: 4 * time.Second, ackThreshold: 8}, }, - {name: "a request timeout which isn't a number", connectionOptions: map[string][]string{"request-timeout": {"soon"}}, wantErr: true}, - {name: "a request timeout of zero", connectionOptions: map[string][]string{"request-timeout": {"0"}}, wantErr: true}, + {name: "a request timeout which isn't a number", connectionOptions: map[string][]string{"request-timeout-ms": {"soon"}}, wantErr: true}, + {name: "a request timeout of zero", connectionOptions: map[string][]string{"request-timeout-ms": {"0"}}, wantErr: true}, {name: "a window which isn't a number", connectionOptions: map[string][]string{"ack-threshold": {"lots"}}, wantErr: true}, {name: "a window of zero", connectionOptions: map[string][]string{"ack-threshold": {"0"}}, wantErr: true}, {name: "a window past the sequence number", connectionOptions: map[string][]string{"ack-threshold": {"32768"}}, wantErr: true}, diff --git a/plc4go/internal/iec608705104/Driver_test.go b/plc4go/internal/iec608705104/Driver_test.go index b29fed99cd2..c9f60d1f685 100644 --- a/plc4go/internal/iec608705104/Driver_test.go +++ b/plc4go/internal/iec608705104/Driver_test.go @@ -99,7 +99,7 @@ func TestDriver_GetConnectionRefusesABadOption(t *testing.T) { testutils.TestContext(t), url.URL{Scheme: "tcp", Host: "127.0.0.1"}, map[string]transports.Transport{}, - map[string][]string{"request-timeout": {"not a number"}}) + map[string][]string{"request-timeout-ms": {"not a number"}}) assert.Error(t, err) assert.Nil(t, connection) diff --git a/plc4go/internal/knxnetip/Connection.go b/plc4go/internal/knxnetip/Connection.go index b48984f8854..ce11c3fcec0 100644 --- a/plc4go/internal/knxnetip/Connection.go +++ b/plc4go/internal/knxnetip/Connection.go @@ -110,7 +110,7 @@ type KnxMemoryReadFragment struct { // (Java: KnxNetIpConnection#HEARTBEAT_INTERVAL_MS) const connectionStateInterval = 60 * time.Second -// defaultRequestTimeout mirrors the "request-timeout" default of the java driver +// defaultRequestTimeout mirrors the "request-timeout-ms" default of the java driver // (KnxNetIpConfiguration#requestTimeout = 10_000ms). const defaultRequestTimeout = 10 * time.Second diff --git a/plc4go/internal/knxnetip/ConnectionDriverSpecificOperations.go b/plc4go/internal/knxnetip/ConnectionDriverSpecificOperations.go index 63ff2b8fe3d..fa89394f260 100644 --- a/plc4go/internal/knxnetip/ConnectionDriverSpecificOperations.go +++ b/plc4go/internal/knxnetip/ConnectionDriverSpecificOperations.go @@ -112,7 +112,7 @@ func (m *Connection) ReadGroupAddress(ctx context.Context, groupAddress []byte, // // The returned channel is always completed exactly once: with a nil error if the gateway // acknowledged and confirmed the frame, and with an error on any failure, including the -// request-timeout which is applied here. (Java: KnxNetIpConnection#onWrite) +// request-timeout-ms which is applied here. (Java: KnxNetIpConnection#onWrite) func (m *Connection) WriteGroupAddress(ctx context.Context, groupAddress []byte, datapointType *driverModel.KnxDatapointType, value values.PlcValue) <-chan KnxWriteResult { result := make(chan KnxWriteResult, 1) diff --git a/plc4go/internal/knxnetip/ConnectionHelper.go b/plc4go/internal/knxnetip/ConnectionHelper.go index faf81eb0230..a412575f5be 100644 --- a/plc4go/internal/knxnetip/ConnectionHelper.go +++ b/plc4go/internal/knxnetip/ConnectionHelper.go @@ -196,16 +196,16 @@ func (m *Connection) getTunnelConnectionType() driverModel.KnxLayer { return driverModel.KnxLayer_TUNNEL_LINK_LAYER } -// getRequestTimeout evaluates the "request-timeout" connection option (in +// getRequestTimeout evaluates the "request-timeout-ms" connection option (in // milliseconds) which limits how long we wait for a gateway reply. // (Java: KnxNetIpConfiguration#requestTimeout) func (m *Connection) getRequestTimeout() time.Duration { - if val, ok := m.options["request-timeout"]; ok && len(val) > 0 { + if val, ok := m.options["request-timeout-ms"]; ok && len(val) > 0 { requestTimeout, err := strconv.ParseUint(val[0], 10, 32) if err == nil && requestTimeout > 0 { return time.Duration(requestTimeout) * time.Millisecond } - m.log.Warn().Str("request-timeout", val[0]).Msg("Invalid value for request-timeout, falling back to the default") + m.log.Warn().Str("request-timeout-ms", val[0]).Msg("Invalid value for request-timeout-ms, falling back to the default") } return defaultRequestTimeout } diff --git a/plc4go/internal/knxnetip/Connection_test.go b/plc4go/internal/knxnetip/Connection_test.go index c79b5ec11fc..550bed4cdb0 100644 --- a/plc4go/internal/knxnetip/Connection_test.go +++ b/plc4go/internal/knxnetip/Connection_test.go @@ -235,10 +235,10 @@ func Test_Connection_getRequestTimeout(t *testing.T) { want time.Duration }{ {"no option", nil, defaultRequestTimeout}, - {"empty option", map[string][]string{"request-timeout": {}}, defaultRequestTimeout}, - {"explicit value", map[string][]string{"request-timeout": {"1500"}}, 1500 * time.Millisecond}, - {"zero falls back", map[string][]string{"request-timeout": {"0"}}, defaultRequestTimeout}, - {"garbage falls back", map[string][]string{"request-timeout": {"soon"}}, defaultRequestTimeout}, + {"empty option", map[string][]string{"request-timeout-ms": {}}, defaultRequestTimeout}, + {"explicit value", map[string][]string{"request-timeout-ms": {"1500"}}, 1500 * time.Millisecond}, + {"zero falls back", map[string][]string{"request-timeout-ms": {"0"}}, defaultRequestTimeout}, + {"garbage falls back", map[string][]string{"request-timeout-ms": {"soon"}}, defaultRequestTimeout}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/plc4go/internal/knxnetip/MessageCodec_test.go b/plc4go/internal/knxnetip/MessageCodec_test.go index ec2ea698980..94af89a84a1 100644 --- a/plc4go/internal/knxnetip/MessageCodec_test.go +++ b/plc4go/internal/knxnetip/MessageCodec_test.go @@ -150,7 +150,7 @@ func Test_CustomMessageHandling_passesNonTunnelingFramesOn(t *testing.T) { // tunneling ACK which used to be swallowed here: DefaultCodec.ReceiveWork skips the // expectations completely for a message the custom handler reports as handled, so a // swallowed ACK made every correlated tunneling-request (e.g. a group-address write) run -// into its request-timeout. +// into its request-timeout-ms. func Test_CustomMessageHandling_tunnelingResponseIsPassedOn(t *testing.T) { codec, transportInstance, intercepted := newTestMessageCodec(t) diff --git a/plc4go/internal/knxnetip/Writer_test.go b/plc4go/internal/knxnetip/Writer_test.go index cbc7dba8fca..4766d1e5627 100644 --- a/plc4go/internal/knxnetip/Writer_test.go +++ b/plc4go/internal/knxnetip/Writer_test.go @@ -216,7 +216,7 @@ func Test_Writer_Write_largeDatapointType(t *testing.T) { // Test_Writer_Write_timeout is the regression test for the write which never completed its // result channel: a silent gateway has to end up as a REQUEST_TIMEOUT response code. func Test_Writer_Write_timeout(t *testing.T) { - connection, codec := newWriterConnection(t, map[string][]string{"request-timeout": {"100"}}, + connection, codec := newWriterConnection(t, map[string][]string{"request-timeout-ms": {"100"}}, func(*writerCodec, spi.Message, spi.HandleMessage, spi.HandleError) error { // The gateway swallows the request without ever answering. return nil @@ -416,7 +416,7 @@ func Test_Writer_Write_ignoresForeignConfirmations(t *testing.T) { "a confirmation of somebody else's frame must not be accepted") return nil } - connection, _ := newWriterConnection(t, map[string][]string{"request-timeout": {"200"}}, foreignConfirmation) + connection, _ := newWriterConnection(t, map[string][]string{"request-timeout-ms": {"200"}}, foreignConfirmation) writeRequestBuilder := connection.WriteRequestBuilder() writeRequestBuilder.AddTagAddress("switch", "1/2/3:DPT_Switch", true) diff --git a/plc4go/internal/modbus/Configuration.go b/plc4go/internal/modbus/Configuration.go index 2d1df1479c9..57d587d72bd 100644 --- a/plc4go/internal/modbus/Configuration.go +++ b/plc4go/internal/modbus/Configuration.go @@ -27,6 +27,7 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) // Configuration is what a modbus connection string can say about the connection as a whole. Ported @@ -64,7 +65,7 @@ const ( defaultUnitIdentifier = uint8(1) // defaultPingAddress reads the first holding register, as plc4j's ModbusTcpConfiguration does. defaultPingAddress = "4x00001:BOOL" - // defaultRequestTimeout is plc4j's request-timeout default of five seconds. + // defaultRequestTimeout is plc4j's request-timeout-ms default of five seconds. defaultRequestTimeout = 5 * time.Second ) @@ -80,14 +81,20 @@ func DefaultConfiguration() Configuration { // ParseFromOptions reads the connection options out of a parsed connection string. func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, connectionOptions) + defer reader.ReportUnknown("modbus") + configuration := DefaultConfiguration() - // plc4j spells the option default-unit-identifier; the Go driver has always called it - // unit-identifier, so both are accepted and the plc4j spelling wins when somebody sets both. - unitIdentifierString := getFromOptions(localLog, connectionOptions, "unit-identifier") - if defaultUnitIdentifierString := getFromOptions(localLog, connectionOptions, "default-unit-identifier"); defaultUnitIdentifierString != "" { - unitIdentifierString = defaultUnitIdentifierString - } + // One name for one concept: "default-unit-identifier", the same as plc4j. This driver also + // accepted "unit-identifier", which plc4j never declared - so one connection string set the + // unit here and was ignored there. Worse, "unit-identifier" *is* the name UMAS uses, where it + // means something subtly different: modbus has a per-tag override ({unit-id: 3}), so this is + // a default, while UMAS has none, so its is absolute. Two spellings meaning two things is + // exactly what this vocabulary exists to stop. Supplying the old name is now reported. + unitIdentifierString := reader.Get("default-unit-identifier") if unitIdentifierString != "" { parsedUint, err := strconv.ParseUint(unitIdentifierString, 10, 8) if err != nil { @@ -96,7 +103,7 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st configuration.unitIdentifier = uint8(parsedUint) } - if byteOrderString := getFromOptions(localLog, connectionOptions, "default-payload-byte-order"); byteOrderString != "" { + if byteOrderString := reader.Get("default-payload-byte-order"); byteOrderString != "" { byteOrder, ok := ByteOrderByName(byteOrderString) if !ok { return Configuration{}, errors.Errorf("Unknown default-payload-byte-order %s", byteOrderString) @@ -104,7 +111,7 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st configuration.defaultPayloadByteOrder = byteOrder } - if pingAddress := getFromOptions(localLog, connectionOptions, "ping-address"); pingAddress != "" { + if pingAddress := reader.Get("ping-address"); pingAddress != "" { if _, err := NewTagHandler().ParseTag(pingAddress); err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing ping-address %s", pingAddress) } @@ -112,13 +119,13 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st } // plc4j states the request timeout in milliseconds. - if requestTimeoutString := getFromOptions(localLog, connectionOptions, "request-timeout"); requestTimeoutString != "" { + if requestTimeoutString := reader.Get("request-timeout-ms"); requestTimeoutString != "" { parsedUint, err := strconv.ParseUint(requestTimeoutString, 10, 32) if err != nil { - return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout %s", requestTimeoutString) + return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout-ms %s", requestTimeoutString) } if parsedUint == 0 { - return Configuration{}, errors.Errorf("request-timeout must be greater than zero. Was %s", requestTimeoutString) + return Configuration{}, errors.Errorf("request-timeout-ms must be greater than zero. Was %s", requestTimeoutString) } configuration.requestTimeout = time.Duration(parsedUint) * time.Millisecond } @@ -126,19 +133,6 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st return configuration, nil } -func getFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string, key string) string { - if optionValues, ok := connectionOptions[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Option must be unique") - } - return optionValues[0] - } - return "" -} - // withRequestTimeout bounds a single request. The codec turns the deadline of the context it is // handed into the lifetime of its expectation (spi/default.defaultCodec.expect), so a deadline is // all it takes to time a request out; a caller who brought a deadline of their own keeps it. diff --git a/plc4go/internal/modbus/Configuration_test.go b/plc4go/internal/modbus/Configuration_test.go index 9248eda22f7..79811e02851 100644 --- a/plc4go/internal/modbus/Configuration_test.go +++ b/plc4go/internal/modbus/Configuration_test.go @@ -50,13 +50,14 @@ func TestParseFromOptions_defaults(t *testing.T) { // plc4j spells the option default-unit-identifier, the Go driver has always called it // unit-identifier. Both work, so that neither an existing connection string nor one copied from // plc4j breaks. -func TestParseFromOptions_unitIdentifierAndItsAlias(t *testing.T) { - assert.Equal(t, uint8(9), parseConfiguration(t, map[string][]string{"unit-identifier": {"9"}}).unitIdentifier) +// One name, the same one plc4j declares. This driver used to accept "unit-identifier" as well, +// which plc4j never did - so that connection string set the unit here and was silently ignored +// there. It is now reported as an unknown option like any other name nothing reads. +func TestParseFromOptions_unitIdentifier(t *testing.T) { assert.Equal(t, uint8(9), parseConfiguration(t, map[string][]string{"default-unit-identifier": {"9"}}).unitIdentifier) - // With both spelled out the plc4j one wins. - both := parseConfiguration(t, map[string][]string{"unit-identifier": {"9"}, "default-unit-identifier": {"3"}}) - assert.Equal(t, uint8(3), both.unitIdentifier) + // The old spelling no longer binds; the default stands. + assert.Equal(t, defaultUnitIdentifier, parseConfiguration(t, map[string][]string{"unit-identifier": {"9"}}).unitIdentifier) } func TestParseFromOptions_defaultPayloadByteOrder(t *testing.T) { @@ -77,7 +78,7 @@ func TestParseFromOptions_pingAddress(t *testing.T) { // The request timeout is stated in milliseconds, as it is in plc4j. func TestParseFromOptions_requestTimeout(t *testing.T) { - configuration := parseConfiguration(t, map[string][]string{"request-timeout": {"250"}}) + configuration := parseConfiguration(t, map[string][]string{"request-timeout-ms": {"250"}}) assert.Equal(t, 250*time.Millisecond, configuration.requestTimeout) } @@ -88,12 +89,12 @@ func TestParseFromOptions_rejectsBadValues(t *testing.T) { name string connectionOptions map[string][]string }{ - {"unit identifier beyond a byte", map[string][]string{"unit-identifier": {"256"}}}, + {"unit identifier beyond a byte", map[string][]string{"default-unit-identifier": {"256"}}}, {"unit identifier that isn't a number", map[string][]string{"default-unit-identifier": {"nope"}}}, {"unknown byte order", map[string][]string{"default-payload-byte-order": {"MIDDLE_ENDIAN"}}}, {"unparsable ping address", map[string][]string{"ping-address": {"this is not an address"}}}, - {"request timeout that isn't a number", map[string][]string{"request-timeout": {"soon"}}}, - {"request timeout of zero", map[string][]string{"request-timeout": {"0"}}}, + {"request timeout that isn't a number", map[string][]string{"request-timeout-ms": {"soon"}}}, + {"request timeout of zero", map[string][]string{"request-timeout-ms": {"0"}}}, } { t.Run(test.name, func(t *testing.T) { _, err := ParseFromOptions(zerolog.Nop(), test.connectionOptions) diff --git a/plc4go/internal/opcua/Configuration.go b/plc4go/internal/opcua/Configuration.go index bc4ad9a01ba..da4d4a0b04d 100644 --- a/plc4go/internal/opcua/Configuration.go +++ b/plc4go/internal/opcua/Configuration.go @@ -30,6 +30,7 @@ import ( readWriteModel "github.com/apache/plc4x/plc4go/protocols/opcua/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) //go:generate go tool plc4xGenerator -type=Configuration @@ -45,12 +46,17 @@ type Configuration struct { SenderCertificate []byte Discovery bool Username string - Password string - SecurityPolicy string - KeyStoreFile string - CertDirectory string - KeyStorePassword string - Ckp *CertificateKeyPair + // The credentials below render as , never as their values - see the generator's + // secret tag. Username is not marked: it says who is connecting, which is a diagnostic. + Password string `secret:"true"` + SecurityPolicy string + KeyStoreFile string + CertDirectory string + KeyStorePassword string `secret:"true"` + // Ckp holds the client's private key. Rendering it prints pointer addresses rather than key + // material, so nothing leaks today - but a struct that holds a key by value later would, and + // a field whose contents must never be printed should say so where it is declared. + Ckp *CertificateKeyPair `secret:"true"` // AllowUnverifiedSecurityPolicies is an explicit opt-in for security policies other than "None". // The plc4go OPC UA secure-channel implementation does not verify server certificates or message // signatures yet, so any other policy is refused unless this is set to true. @@ -59,44 +65,57 @@ type Configuration struct { log zerolog.Logger } -// transportLevelOptionKeys are option keys (lower-cased) which are consumed by the transport -// layer (or injected by the driver itself) rather than mapping to Configuration fields, so -// they must not be reported as unknown options. -var transportLevelOptionKeys = map[string]struct{}{ - "defaulttcpport": {}, - "defaultudpport": {}, - "connect-timeout": {}, - "so-reuse": {}, - "transport-type": {}, - "transport-port-range": {}, - "speed-factor": {}, - "failtesttransport": {}, - "simulatedlatency": {}, +// fieldForOption maps a connection-string option to the Configuration field it sets. +// +// The names are the ones PLC4J declares and the documentation lists, not this struct's Go field +// names. Deriving them from the field names is what made this driver read "keyStoreFile" and +// "keyStorePassword": the documented "tls.keystore" and "tls.keystore-password" reached it as +// unknown options and were ignored, so the two bindings disagreed about the same string. +// +// Keys are lower-case; lookup lower-cases the option, which keeps the case-insensitive matching +// this driver has always had. +var fieldForOption = map[string]string{ + "discovery": "Discovery", + "username": "Username", + "password": "Password", + "security-policy": "SecurityPolicy", + "tls.keystore": "KeyStoreFile", + "tls.keystore-password": "KeyStorePassword", + // No PLC4J counterpart: this binding generates its certificate into a directory rather than + // taking a key store, so the name is this binding's own - in the shared spelling. + "cert-directory": "CertDirectory", + "allow-unverified-security-policies": "AllowUnverifiedSecurityPolicies", } func ParseFromOptions(log zerolog.Logger, options map[string][]string) (Configuration, error) { configuration := createDefaultConfiguration() reflectConfiguration := reflect.ValueOf(&configuration).Elem() - // Match option keys case-insensitively against the exported Configuration field names. - // (Option keys were previously title-cased, which silently broke camelCase keys like - // securityPolicy and thereby dropped requested security settings.) - fieldNamesByLowerCase := map[string]string{} - for i := 0; i < reflectConfiguration.NumField(); i++ { - field := reflectConfiguration.Type().Field(i) - if !field.IsExported() { - continue - } - fieldNamesByLowerCase[strings.ToLower(field.Name)] = field.Name - } for optionKey := range options { - fieldName, ok := fieldNamesByLowerCase[strings.ToLower(optionKey)] + fieldName, ok := fieldForOption[strings.ToLower(optionKey)] if !ok { - if _, isTransportOption := transportLevelOptionKeys[strings.ToLower(optionKey)]; isTransportOption { + // The driver manager's bookkeeping, not a user option. + if optionKey == spiOptions.ActiveTransportOption { continue } - // Fail on unknown options instead of silently ignoring them: a typo in a - // security-relevant option must not silently fall back to defaults. - return Configuration{}, errors.Errorf("unknown option %s", optionKey) + // Read by the transport rather than by this driver. The names come from the + // transports themselves, which register what they read, so this driver does not + // keep its own copy of a list that would drift from them. + if spiOptions.IsTransportOption(optionKey) { + continue + } + // Warn rather than fail, matching plc4j, which reports an unknown parameter and + // carries on for every driver. This driver used to be the only one anywhere in + // PLC4X that refused the connection, which meant one connection string was + // accepted by plc4j and rejected here. + // + // The reason it refused is still real and is now carried by the warning instead: + // a typo in a security-relevant option (securityPolicy, allowUnverified...) falls + // back to a default, and the operator has to see that it did. An unread option is + // reported by name, so it is visible - but it no longer stops the connection. + log.Warn(). + Str("option", optionKey). + Msg("Connection string option is not known to the opcua driver and is ignored") + continue } optionValue := getFromOptions(log, options, optionKey) if optionValue == "" { @@ -137,8 +156,8 @@ func (c *Configuration) validateSecurityPolicy() error { return nil } if !c.AllowUnverifiedSecurityPolicies { - return errors.Errorf("securityPolicy %s is not supported: the plc4go OPC UA driver does not verify server certificates or message signatures yet. "+ - "Set allowUnverifiedSecurityPolicies=true to connect anyway (NOT recommended for production use)", c.SecurityPolicy) + return errors.Errorf("security-policy %s is not supported: the plc4go OPC UA driver does not verify server certificates or message signatures yet. "+ + "Set allow-unverified-security-policies=true to connect anyway (NOT recommended for production use)", c.SecurityPolicy) } c.log.Warn(). Str("securityPolicy", c.SecurityPolicy). diff --git a/plc4go/internal/opcua/Configuration_plc4xgen.go b/plc4go/internal/opcua/Configuration_plc4xgen.go index f7a1e27fb4d..3fe0bba3893 100644 --- a/plc4go/internal/opcua/Configuration_plc4xgen.go +++ b/plc4go/internal/opcua/Configuration_plc4xgen.go @@ -107,7 +107,7 @@ func (d *Configuration) SerializeWithWriteBuffer(ctx context.Context, writeBuffe return err } - if err := writeBuffer.WriteString("password", uint32(len(d.Password)*8), d.Password, utils.WithEncoding("UTF-8")); err != nil { + if err := writeBuffer.WriteString("password", uint32(len("")*8), "", utils.WithEncoding("UTF-8")); err != nil { return err } @@ -123,17 +123,12 @@ func (d *Configuration) SerializeWithWriteBuffer(ctx context.Context, writeBuffe return err } - if err := writeBuffer.WriteString("keyStorePassword", uint32(len(d.KeyStorePassword)*8), d.KeyStorePassword, utils.WithEncoding("UTF-8")); err != nil { + if err := writeBuffer.WriteString("keyStorePassword", uint32(len("")*8), "", utils.WithEncoding("UTF-8")); err != nil { return err } - if d.Ckp != nil { - { - _value := fmt.Sprintf("%v", d.Ckp) - if err := writeBuffer.WriteString("ckp", uint32(len(_value)*8), _value, utils.WithEncoding("UTF-8")); err != nil { - return err - } - } + if err := writeBuffer.WriteString("ckp", uint32(len("")*8), "", utils.WithEncoding("UTF-8")); err != nil { + return err } if err := writeBuffer.WriteBit("allowUnverifiedSecurityPolicies", d.AllowUnverifiedSecurityPolicies); err != nil { diff --git a/plc4go/internal/opcua/Configuration_test.go b/plc4go/internal/opcua/Configuration_test.go new file mode 100644 index 00000000000..bdc2d2652f9 --- /dev/null +++ b/plc4go/internal/opcua/Configuration_test.go @@ -0,0 +1,138 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package opcua + +import ( + "bytes" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + spiOptions "github.com/apache/plc4x/plc4go/spi/options" + _ "github.com/apache/plc4x/plc4go/spi/transports/tcp" +) + +// An option this driver does not know is reported and ignored, rather than refusing the +// connection. +// +// This driver used to be the only one in PLC4X that failed on an unknown option, so one +// connection string was accepted by plc4j and by every other Go driver, and rejected here. The +// reason it failed is still real - a typo in a security-relevant option falls back to a default - +// which is why the option has to be named in the log rather than passed over in silence. +func TestParseFromOptions_reportsAnUnknownOptionAndCarriesOn(t *testing.T) { + var logged bytes.Buffer + log := zerolog.New(&logged) + + configuration, err := ParseFromOptions(log, map[string][]string{ + "securityPolicy": {"None"}, + "scurityPolicy": {"Basic256"}, // the typo that used to refuse the connection + }) + + require.NoError(t, err, "an unknown option must not refuse the connection") + assert.Equal(t, "None", configuration.SecurityPolicy, "the options it does know still apply") + assert.Contains(t, logged.String(), "scurityPolicy", "the ignored option must be named") +} + +// The names PLC4J declares and the documentation lists are the ones that bind here. They used to +// be derived from this struct's Go field names, so "tls.keystore" reached the driver as an unknown +// option while "keyStoreFile" - a name no document mentions - was what actually worked. +func TestParseFromOptions_readsTheDocumentedNames(t *testing.T) { + var logged bytes.Buffer + log := zerolog.New(&logged) + + configuration, err := ParseFromOptions(log, map[string][]string{ + "security-policy": {"Basic256Sha256"}, + "allow-unverified-security-policies": {"true"}, + "tls.keystore": {"/etc/plc4x/client.p12"}, + "tls.keystore-password": {"hunter2"}, + "username": {"operator"}, + }) + + require.NoError(t, err) + assert.Equal(t, "Basic256Sha256", configuration.SecurityPolicy) + assert.True(t, configuration.AllowUnverifiedSecurityPolicies, "the opt-in binds under its documented name too") + assert.Equal(t, "/etc/plc4x/client.p12", configuration.KeyStoreFile) + assert.Equal(t, "hunter2", configuration.KeyStorePassword) + assert.Equal(t, "operator", configuration.Username) + assert.NotContains(t, logged.String(), "not known", "every documented name is read") +} + +// The pre-migration spelling is reported like any other unknown option, so an upgraded connection +// string says what it lost rather than quietly falling back to a default. +func TestParseFromOptions_reportsTheOldFieldNameSpelling(t *testing.T) { + var logged bytes.Buffer + log := zerolog.New(&logged) + + configuration, err := ParseFromOptions(log, map[string][]string{"keyStoreFile": {"/etc/old.p12"}}) + + require.NoError(t, err) + assert.Empty(t, configuration.KeyStoreFile) + assert.Contains(t, logged.String(), "keyStoreFile") +} + +// A transport-level option belongs to another consumer and is not this driver's to report. The +// TCP transport, linked in above, is what declares that it reads this one. +func TestParseFromOptions_saysNothingAboutTransportOptions(t *testing.T) { + var logged bytes.Buffer + log := zerolog.New(&logged) + + _, err := ParseFromOptions(log, map[string][]string{"tcp.connect-timeout-ms": {"5000"}}) + + require.NoError(t, err) + assert.NotContains(t, logged.String(), "tcp.connect-timeout-ms") +} + +// The driver manager stamps the transport it selected into the options; that marker is its +// bookkeeping, not a user option, and must not be reported as one. +func TestParseFromOptions_saysNothingAboutTheActiveTransportMarker(t *testing.T) { + var logged bytes.Buffer + log := zerolog.New(&logged) + + _, err := ParseFromOptions(log, map[string][]string{spiOptions.ActiveTransportOption: {"tcp"}}) + + require.NoError(t, err) + assert.Empty(t, logged.String()) +} + +// A password must never appear in a rendering. The opcua Configuration and SecureChannel render +// themselves through generated code, which used to write the password verbatim - so turning on +// debug logging wrote the PLC password into the log. +// +// The value is planted and then looked for, rather than the field list being asserted: that is +// what makes this hold for a secret added later. A test that checked "password renders as +// " would pass while a newly added token leaked. +func TestConfiguration_NoSecretIsRendered(t *testing.T) { + const sentinel = "hunter2-sentinel-value" + + configuration, err := ParseFromOptions(zerolog.Nop(), map[string][]string{ + "username": {"operator"}, + "password": {sentinel}, + "keyStorePassword": {sentinel}, + }) + require.NoError(t, err) + require.Equal(t, sentinel, configuration.Password, "the value is still available to the driver") + + rendered := configuration.String() + assert.NotContains(t, rendered, sentinel, "no secret may appear in a rendering") + assert.Contains(t, rendered, "", "and the reader is told a secret is configured") + assert.Contains(t, rendered, "operator", "the username is not a secret - it says who is connecting") +} diff --git a/plc4go/internal/opcua/SecureChannel.go b/plc4go/internal/opcua/SecureChannel.go index 4e7e4b4d44a..b67058349fb 100644 --- a/plc4go/internal/opcua/SecureChannel.go +++ b/plc4go/internal/opcua/SecureChannel.go @@ -89,10 +89,10 @@ type SecureChannel struct { discovery bool certFile string keyStoreFile string - ckp CertificateKeyPair + ckp CertificateKeyPair `secret:"true"` // the client's private key - see Configuration.Ckp endpoint readWriteModel.PascalString username string - password string + password string `secret:"true"` securityPolicy string publicCertificate readWriteModel.PascalByteString thumbprint readWriteModel.PascalByteString diff --git a/plc4go/internal/opcua/SecureChannel_plc4xgen.go b/plc4go/internal/opcua/SecureChannel_plc4xgen.go index 7afe39227e2..57a23048cf0 100644 --- a/plc4go/internal/opcua/SecureChannel_plc4xgen.go +++ b/plc4go/internal/opcua/SecureChannel_plc4xgen.go @@ -94,12 +94,9 @@ func (d *SecureChannel) SerializeWithWriteBuffer(ctx context.Context, writeBuffe if err := writeBuffer.WriteString("keyStoreFile", uint32(len(d.keyStoreFile)*8), d.keyStoreFile, utils.WithEncoding("UTF-8")); err != nil { return err } - { - _value := fmt.Sprintf("%v", d.ckp) - if err := writeBuffer.WriteString("ckp", uint32(len(_value)*8), _value, utils.WithEncoding("UTF-8")); err != nil { - return err - } + if err := writeBuffer.WriteString("ckp", uint32(len("")*8), "", utils.WithEncoding("UTF-8")); err != nil { + return err } if d.endpoint != nil { @@ -125,7 +122,7 @@ func (d *SecureChannel) SerializeWithWriteBuffer(ctx context.Context, writeBuffe return err } - if err := writeBuffer.WriteString("password", uint32(len(d.password)*8), d.password, utils.WithEncoding("UTF-8")); err != nil { + if err := writeBuffer.WriteString("password", uint32(len("")*8), "", utils.WithEncoding("UTF-8")); err != nil { return err } diff --git a/plc4go/internal/s7/Configuration.go b/plc4go/internal/s7/Configuration.go index 25760b26985..d1a068eadee 100644 --- a/plc4go/internal/s7/Configuration.go +++ b/plc4go/internal/s7/Configuration.go @@ -26,6 +26,7 @@ import ( readWriteModel "github.com/apache/plc4x/plc4go/protocols/s7/readwrite/model" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) type Configuration struct { @@ -40,6 +41,17 @@ type Configuration struct { } func ParseFromOptions(localLog zerolog.Logger, options map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, options) + defer reader.ReportUnknown("s7") + + // The rack and slot names carry the "cotp." prefix because that is the spelling PLC4J + // declares (S7CotpTransportConfiguration, resolved under the COTP transport's prefix) and the + // one every s7 example in the documentation uses. This binding read them unprefixed, so the + // documented connection string silently did nothing here - the divergence the configuration + // parity test exists to catch. The unprefixed names are now reported as unknown. + configuration := Configuration{ localRack: 1, localSlot: 1, @@ -50,35 +62,35 @@ func ParseFromOptions(localLog zerolog.Logger, options map[string][]string) (Con maxAmqCallee: 8, controllerType: readWriteModel.ControllerType_ANY, } - if localRackString := getFromOptions(localLog, options, "local-rack"); localRackString != "" { + if localRackString := reader.Get("cotp.local-rack"); localRackString != "" { parsedInt, err := strconv.ParseInt(localRackString, 10, 32) if err != nil { - return Configuration{}, errors.Wrap(err, "Error parsing local-rack") + return Configuration{}, errors.Wrap(err, "Error parsing cotp.local-rack") } configuration.localRack = int32(parsedInt) } - if localSlotString := getFromOptions(localLog, options, "local-slot"); localSlotString != "" { + if localSlotString := reader.Get("cotp.local-slot"); localSlotString != "" { parsedInt, err := strconv.ParseInt(localSlotString, 10, 32) if err != nil { - return Configuration{}, errors.Wrap(err, "Error parsing local-slot") + return Configuration{}, errors.Wrap(err, "Error parsing cotp.local-slot") } configuration.localSlot = int32(parsedInt) } - if remoteRackString := getFromOptions(localLog, options, "remote-rack"); remoteRackString != "" { + if remoteRackString := reader.Get("cotp.remote-rack"); remoteRackString != "" { parsedInt, err := strconv.ParseInt(remoteRackString, 10, 32) if err != nil { - return Configuration{}, errors.Wrap(err, "Error parsing remote-rack") + return Configuration{}, errors.Wrap(err, "Error parsing cotp.remote-rack") } configuration.remoteRack = int32(parsedInt) } - if remoteSlotString := getFromOptions(localLog, options, "remote-slot"); remoteSlotString != "" { + if remoteSlotString := reader.Get("cotp.remote-slot"); remoteSlotString != "" { parsedInt, err := strconv.ParseInt(remoteSlotString, 10, 32) if err != nil { - return Configuration{}, errors.Wrap(err, "Error parsing remote-slot") + return Configuration{}, errors.Wrap(err, "Error parsing cotp.remote-slot") } configuration.remoteSlot = int32(parsedInt) } - if controllerTypeString := getFromOptions(localLog, options, "controller-type"); controllerTypeString != "" { + if controllerTypeString := reader.Get("controller-type"); controllerTypeString != "" { controllerType, ok := readWriteModel.ControllerTypeByName(controllerTypeString) if !ok { return Configuration{}, errors.Errorf("Unknown controller type %s", controllerTypeString) @@ -86,7 +98,7 @@ func ParseFromOptions(localLog zerolog.Logger, options map[string][]string) (Con configuration.controllerType = controllerType } - pduSizeString := getFromOptions(localLog, options, "pdu-size") + pduSizeString := reader.Get("pdu-size") if pduSizeString != "" { parsedUint, err := strconv.ParseUint(pduSizeString, 10, 16) if err != nil { @@ -95,7 +107,7 @@ func ParseFromOptions(localLog zerolog.Logger, options map[string][]string) (Con configuration.pduSize = uint16(parsedUint) } - if maxAmqCallerString := getFromOptions(localLog, options, "max-amq-caller"); maxAmqCallerString != "" { + if maxAmqCallerString := reader.Get("max-amq-caller"); maxAmqCallerString != "" { parsedUint, err := strconv.ParseUint(maxAmqCallerString, 10, 16) if err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing max-amq-caller %s", maxAmqCallerString) @@ -103,7 +115,7 @@ func ParseFromOptions(localLog zerolog.Logger, options map[string][]string) (Con configuration.maxAmqCaller = uint16(parsedUint) } - if maxAmqCalleeString := getFromOptions(localLog, options, "max-amq-callee"); maxAmqCalleeString != "" { + if maxAmqCalleeString := reader.Get("max-amq-callee"); maxAmqCalleeString != "" { parsedUint, err := strconv.ParseUint(maxAmqCalleeString, 10, 16) if err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing max-amq-callee %s", maxAmqCalleeString) @@ -112,16 +124,3 @@ func ParseFromOptions(localLog zerolog.Logger, options map[string][]string) (Con } return configuration, nil } - -func getFromOptions(localLog zerolog.Logger, options map[string][]string, key string) string { - if optionValues, ok := options[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Options %s must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/slmp/Configuration.go b/plc4go/internal/slmp/Configuration.go index cd0bc90a6e3..f366f5234a6 100644 --- a/plc4go/internal/slmp/Configuration.go +++ b/plc4go/internal/slmp/Configuration.go @@ -27,6 +27,7 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) // Configuration is what an slmp connection string can say about the connection. Ported from plc4j's @@ -44,7 +45,7 @@ type Configuration struct { const ( // defaultMonitoringTimer is plc4j's @IntDefaultValue(0x0000) for the monitoring-timer option. defaultMonitoringTimer = uint16(0x0000) - // defaultRequestTimeout is plc4j's @IntDefaultValue(5_000) for the request-timeout option. + // defaultRequestTimeout is plc4j's @IntDefaultValue(5_000) for the request-timeout-ms option. defaultRequestTimeout = 5 * time.Second ) @@ -63,9 +64,14 @@ func DefaultConfiguration() Configuration { // object is populated by field injection and its setters are bypassed. Here the parse is the only // way in, so both checks live at the parse. func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, connectionOptions) + defer reader.ReportUnknown("slmp") + configuration := DefaultConfiguration() - if monitoringTimerString := getFromOptions(localLog, connectionOptions, "monitoring-timer"); monitoringTimerString != "" { + if monitoringTimerString := reader.Get("monitoring-timer"); monitoringTimerString != "" { parsedInt, err := strconv.ParseUint(monitoringTimerString, 10, 16) if err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing monitoring-timer %s (it is an unsigned 16-bit field in the 3E frame, so it has to be in [0, 65535])", monitoringTimerString) @@ -73,15 +79,15 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st configuration.monitoringTimer = uint16(parsedInt) } - if requestTimeoutString := getFromOptions(localLog, connectionOptions, "request-timeout"); requestTimeoutString != "" { + if requestTimeoutString := reader.Get("request-timeout-ms"); requestTimeoutString != "" { parsedInt, err := strconv.ParseUint(requestTimeoutString, 10, 32) if err != nil { - return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout %s", requestTimeoutString) + return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout-ms %s", requestTimeoutString) } if parsedInt == 0 { // plc4j rejects this at connect time with the same reasoning: a non-positive timeout // would time out every request immediately. - return Configuration{}, errors.New("request-timeout must be greater than zero") + return Configuration{}, errors.New("request-timeout-ms must be greater than zero") } configuration.requestTimeout = time.Duration(parsedInt) * time.Millisecond } @@ -92,17 +98,3 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st func (c Configuration) String() string { return fmt.Sprintf("slmp.Configuration{monitoringTimer: 0x%04X, requestTimeout: %s}", c.monitoringTimer, c.requestTimeout) } - -// getFromOptions plucks a single-valued option out of the parsed connection string. -func getFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string, key string) string { - if optionValues, ok := connectionOptions[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Option must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/slmp/Configuration_test.go b/plc4go/internal/slmp/Configuration_test.go index 47ec0fca3c3..37e25899278 100644 --- a/plc4go/internal/slmp/Configuration_test.go +++ b/plc4go/internal/slmp/Configuration_test.go @@ -50,12 +50,12 @@ func TestParseFromOptions(t *testing.T) { }, { name: "both options", - connectionOptions: map[string][]string{"monitoring-timer": {"250"}, "request-timeout": {"1500"}}, + connectionOptions: map[string][]string{"monitoring-timer": {"250"}, "request-timeout-ms": {"1500"}}, want: Configuration{monitoringTimer: 250, requestTimeout: 1500 * time.Millisecond}, }, { name: "the request timeout is spelled in milliseconds", - connectionOptions: map[string][]string{"request-timeout": {"250"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"250"}}, want: Configuration{monitoringTimer: defaultMonitoringTimer, requestTimeout: 250 * time.Millisecond}, }, { @@ -84,12 +84,12 @@ func TestParseFromOptions(t *testing.T) { // A zero timeout would time out every request immediately, which is never what anyone // meant. plc4j rejects it at connect time. name: "a zero request timeout is refused", - connectionOptions: map[string][]string{"request-timeout": {"0"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"0"}}, wantErr: true, }, { name: "a non-numeric request timeout is refused", - connectionOptions: map[string][]string{"request-timeout": {"later"}}, + connectionOptions: map[string][]string{"request-timeout-ms": {"later"}}, wantErr: true, }, { diff --git a/plc4go/internal/umas/Configuration.go b/plc4go/internal/umas/Configuration.go index a7af648fcbf..87deb301978 100644 --- a/plc4go/internal/umas/Configuration.go +++ b/plc4go/internal/umas/Configuration.go @@ -27,6 +27,7 @@ import ( "github.com/rs/zerolog" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) // Configuration is what a umas connection string can say about the connection. Ported from plc4j's @@ -47,7 +48,7 @@ type Configuration struct { const ( // defaultUnitIdentifier is plc4j's @IntDefaultValue(0) for the unit-identifier option. defaultUnitIdentifier = uint8(0) - // defaultRequestTimeout is plc4j's @IntDefaultValue(4000) for the request-timeout option. + // defaultRequestTimeout is plc4j's @IntDefaultValue(4000) for the request-timeout-ms option. defaultRequestTimeout = 4000 * time.Millisecond // defaultMaxFrameSize is plc4j's @IntDefaultValue(65535) for the max-frame-size option. defaultMaxFrameSize = uint16(65535) @@ -68,9 +69,14 @@ func DefaultConfiguration() Configuration { // ParseFromOptions reads the connection options out of a parsed connection string. The timeout is // spelled in milliseconds, the way plc4j's @IntDefaultValue(4000) does. func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string) (Configuration, error) { + // Every option this driver reads goes through the reader, so the ones nothing read can be + // reported rather than silently discarded. Deferred, so no return path can skip it. + reader := spiOptions.NewOptionReader(localLog, connectionOptions) + defer reader.ReportUnknown("umas") + configuration := DefaultConfiguration() - if unitIdentifierString := getFromOptions(localLog, connectionOptions, "unit-identifier"); unitIdentifierString != "" { + if unitIdentifierString := reader.Get("unit-identifier"); unitIdentifierString != "" { parsedInt, err := strconv.ParseUint(unitIdentifierString, 10, 8) if err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing unit-identifier %s (has to fit into a single byte)", unitIdentifierString) @@ -78,18 +84,18 @@ func ParseFromOptions(localLog zerolog.Logger, connectionOptions map[string][]st configuration.unitIdentifier = uint8(parsedInt) } - if requestTimeoutString := getFromOptions(localLog, connectionOptions, "request-timeout"); requestTimeoutString != "" { + if requestTimeoutString := reader.Get("request-timeout-ms"); requestTimeoutString != "" { parsedInt, err := strconv.ParseUint(requestTimeoutString, 10, 32) if err != nil { - return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout %s", requestTimeoutString) + return Configuration{}, errors.Wrapf(err, "Error parsing request-timeout-ms %s", requestTimeoutString) } if parsedInt == 0 { - return Configuration{}, errors.New("request-timeout must be greater than zero") + return Configuration{}, errors.New("request-timeout-ms must be greater than zero") } configuration.requestTimeout = time.Duration(parsedInt) * time.Millisecond } - if maxFrameSizeString := getFromOptions(localLog, connectionOptions, "max-frame-size"); maxFrameSizeString != "" { + if maxFrameSizeString := reader.Get("max-frame-size"); maxFrameSizeString != "" { parsedInt, err := strconv.ParseUint(maxFrameSizeString, 10, 16) if err != nil { return Configuration{}, errors.Wrapf(err, "Error parsing max-frame-size %s (has to fit into two bytes)", maxFrameSizeString) @@ -107,17 +113,3 @@ func (c Configuration) String() string { return fmt.Sprintf("umas.Configuration{unitIdentifier: %d, requestTimeout: %s, maxFrameSize: %d}", c.unitIdentifier, c.requestTimeout, c.maxFrameSize) } - -// getFromOptions plucks a single-valued option out of the parsed connection string. -func getFromOptions(localLog zerolog.Logger, connectionOptions map[string][]string, key string) string { - if optionValues, ok := connectionOptions[key]; ok { - if len(optionValues) <= 0 { - return "" - } - if len(optionValues) > 1 { - localLog.Warn().Str("key", key).Msg("Option must be unique") - } - return optionValues[0] - } - return "" -} diff --git a/plc4go/internal/umas/Configuration_test.go b/plc4go/internal/umas/Configuration_test.go index 3aab2847685..ade0dfcec5a 100644 --- a/plc4go/internal/umas/Configuration_test.go +++ b/plc4go/internal/umas/Configuration_test.go @@ -28,7 +28,7 @@ import ( "github.com/stretchr/testify/require" ) -// The defaults are plc4j's UmasConfiguration annotations: unit-identifier 0, request-timeout 4000 ms, +// The defaults are plc4j's UmasConfiguration annotations: unit-identifier 0, request-timeout-ms 4000 ms, // max-frame-size 65535. func TestDefaultConfiguration(t *testing.T) { configuration := DefaultConfiguration() @@ -61,17 +61,17 @@ func TestParseFromOptions(t *testing.T) { }, { name: "the request timeout is spelled in milliseconds", - options: map[string][]string{"request-timeout": {"1500"}}, + options: map[string][]string{"request-timeout-ms": {"1500"}}, want: Configuration{unitIdentifier: 0, requestTimeout: 1500 * time.Millisecond, maxFrameSize: defaultMaxFrameSize}, }, { name: "a request timeout of zero would never wait for an answer", - options: map[string][]string{"request-timeout": {"0"}}, + options: map[string][]string{"request-timeout-ms": {"0"}}, wantErr: true, }, { name: "a non numeric request timeout is refused", - options: map[string][]string{"request-timeout": {"soon"}}, + options: map[string][]string{"request-timeout-ms": {"soon"}}, wantErr: true, }, { @@ -94,9 +94,9 @@ func TestParseFromOptions(t *testing.T) { { name: "every option at once", options: map[string][]string{ - "unit-identifier": {"1"}, - "request-timeout": {"250"}, - "max-frame-size": {"1024"}, + "unit-identifier": {"1"}, + "request-timeout-ms": {"250"}, + "max-frame-size": {"1024"}, }, want: Configuration{unitIdentifier: 1, requestTimeout: 250 * time.Millisecond, maxFrameSize: 1024}, }, diff --git a/plc4go/internal/umas/Driver_test.go b/plc4go/internal/umas/Driver_test.go index d2184bf0c47..321da06274f 100644 --- a/plc4go/internal/umas/Driver_test.go +++ b/plc4go/internal/umas/Driver_test.go @@ -97,7 +97,7 @@ func TestDriver_ReportsBadDriverOptions(t *testing.T) { } connection, err := driver.GetConnection(testutils.TestContext(t), url.URL{Scheme: "test", Host: "localhost"}, availableTransports, - map[string][]string{"request-timeout": {"never"}}) + map[string][]string{"request-timeout-ms": {"never"}}) assert.Error(t, err) assert.Nil(t, connection) } diff --git a/plc4go/pkg/api/PlcDriverManager.go b/plc4go/pkg/api/PlcDriverManager.go index a9667bd9cf4..9e8344d411f 100644 --- a/plc4go/pkg/api/PlcDriverManager.go +++ b/plc4go/pkg/api/PlcDriverManager.go @@ -190,14 +190,14 @@ func (m *plcDriverManger) GetTransport(transportName string, _ string, _ map[str } func (m *plcDriverManger) GetConnection(ctx context.Context, connectionString string) (PlcConnection, error) { - m.log.Debug().Str("connectionString", connectionString).Msg("Getting connection for connectionString") + m.log.Debug().Str("connectionString", options.RedactConnectionString(connectionString)).Msg("Getting connection for connectionString") // Parse the connection string. connectionUrl, err := url.Parse(connectionString) if err != nil { m.log.Error().Err(err).Msg("Error parsing connection") return nil, errors.Wrap(err, "error parsing connection string") } - m.log.Debug().Stringer("connectionUrl", connectionUrl).Msg("parsed connection URL") + m.log.Debug().Str("connectionUrl", options.RedactConnectionString(connectionUrl.String())).Msg("parsed connection URL") // The options will be used to configure both the transports as well as the connections/drivers configOptions := connectionUrl.Query() @@ -209,7 +209,7 @@ func (m *plcDriverManger) GetConnection(ctx context.Context, connectionString st m.log.Err(err).Str("driverName", driverName).Msg("Couldn't get driver for driverName") return nil, errors.Wrap(err, "error getting driver for connection string") } - m.log.Debug().Stringer("connectionUrl", connectionUrl).Str("protocolName", driver.GetProtocolName()).Msg("got driver protocolName") + m.log.Debug().Str("connectionUrl", options.RedactConnectionString(connectionUrl.String())).Str("protocolName", driver.GetProtocolName()).Msg("got driver protocolName") // If a transport is provided alongside the driver, the URL content is decoded as "opaque" data // Then we have to re-parse that to get the transport code as well as the host & port information. @@ -251,6 +251,11 @@ func (m *plcDriverManger) GetConnection(ctx context.Context, connectionString st } m.log.Debug().Stringer("transportUrl", &transportUrl).Msg("Assembled transport url") + // Tell option reporting which transport is on duty, so it can excuse that transport's + // options and no other's. Stamped unconditionally: a user-supplied value here would be a + // lie about the connection. + configOptions[options.ActiveTransportOption] = []string{transportName} + // Create a new connection return driver.GetConnection(ctx, transportUrl, m.transports, configOptions) } diff --git a/plc4go/pkg/api/PlcDriverManger_test.go b/plc4go/pkg/api/PlcDriverManger_test.go index 8c58ae2bae2..a9af7caed8f 100644 --- a/plc4go/pkg/api/PlcDriverManger_test.go +++ b/plc4go/pkg/api/PlcDriverManger_test.go @@ -412,6 +412,25 @@ func Test_plcDriverManger_GetConnection(t *testing.T) { } } +// The manager is the one place that knows which transport the connection string selected, and it +// knows it before any configuration is parsed. Stamping it into the options lets ReportUnknown +// excuse the active transport's options and no other's. +func Test_plcDriverManger_GetConnectionStampsTheActiveTransport(t *testing.T) { + driver := NewMockPlcDriver(t) + expect := driver.EXPECT() + expect.GetProtocolName().Return("test") + expect.GetDefaultTransport().Return("test") + expect.GetConnection(mock.Anything, mock.Anything, mock.Anything, mock.MatchedBy(func(configOptions map[string][]string) bool { + values := configOptions[options.ActiveTransportOption] + return len(values) == 1 && values[0] == "test" + })).Return(nil, nil) + m := &plcDriverManger{drivers: map[string]PlcDriver{"test": driver}} + m.log = produceTestingLogger(t) + + _, err := m.GetConnection(t.Context(), "test://something?some-option=1") + assert.NoError(t, err) +} + func Test_plcDriverManger_GetDriver(t *testing.T) { type fields struct { drivers map[string]PlcDriver diff --git a/plc4go/pkg/api/cache/PlcConnectionCache.go b/plc4go/pkg/api/cache/PlcConnectionCache.go index 64690de16f3..6f868fbcd94 100644 --- a/plc4go/pkg/api/cache/PlcConnectionCache.go +++ b/plc4go/pkg/api/cache/PlcConnectionCache.go @@ -147,7 +147,7 @@ func (c *plcConnectionCache) onConnectionEvent(event connectionEvent) { c.tracer.AddTrace("destroy-connection", errorEvent.getError().Error()) } c.log.Debug(). - Str("connectionString", connectionContainerInstance.connectionString). + Str("connectionString", options.RedactConnectionString(connectionContainerInstance.connectionString)). Err(errorEvent.getError()). Msg("Connection reported an error event") } @@ -179,7 +179,7 @@ func (c *plcConnectionCache) GetConnection(ctx context.Context, connectionString if c.tracer != nil { c.tracer.AddTrace("get-connection", "create new cached connection") } - c.log.Debug().Str("connectionString", connectionString).Msg("Create new cached connection") + c.log.Debug().Str("connectionString", options.RedactConnectionString(connectionString)).Msg("Create new cached connection") // Create a new connection container. cc := newConnectionContainer(c.log, c.driverManager, connectionString) cc.maxIdleTime = c.maxIdleTime @@ -210,7 +210,7 @@ func (c *plcConnectionCache) GetConnection(ctx context.Context, connectionString select { case conn := <-connChan: // Wait till we get a lease. c.log.Debug(). - Str("connectionString", connectionString). + Str("connectionString", options.RedactConnectionString(connectionString)). Stringer("conn", conn). Msg("Successfully got lease to connection") if c.tracer != nil { @@ -238,7 +238,7 @@ func (c *plcConnectionCache) GetConnection(ctx context.Context, connectionString if c.tracer != nil { c.tracer.AddTransactionalTrace(txId, "get-connection", "timeout") } - c.log.Debug().Str("connectionString", connectionString).Msg("Timeout while waiting for connection.") + c.log.Debug().Str("connectionString", options.RedactConnectionString(connectionString)).Msg("Timeout while waiting for connection.") return nil, errors.New("timeout while waiting for connection") } } diff --git a/plc4go/pkg/api/cache/connectionContainer.go b/plc4go/pkg/api/cache/connectionContainer.go index 5162be623df..08dd3209f5f 100644 --- a/plc4go/pkg/api/cache/connectionContainer.go +++ b/plc4go/pkg/api/cache/connectionContainer.go @@ -29,6 +29,7 @@ import ( plc4go "github.com/apache/plc4x/plc4go/pkg/api" "github.com/apache/plc4x/plc4go/spi/errors" + spiOptions "github.com/apache/plc4x/plc4go/spi/options" ) type connectionContainer struct { @@ -79,7 +80,7 @@ func newConnectionContainer(log zerolog.Logger, driverManager plc4go.PlcDriverMa } func (c *connectionContainer) connect(ctx context.Context) { - c.log.Debug().Str("connectionString", c.connectionString).Msg("Connecting new cached connection ...") + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)).Msg("Connecting new cached connection ...") // Initialize the new connection. connection, err := c.driverManager.GetConnection(ctx, c.connectionString) @@ -93,7 +94,7 @@ func (c *connectionContainer) connect(ctx context.Context) { // If the connection was successful, pass the active connection into the container. // If something went wrong, we have to remove the connection from the cache and return the error. if err != nil { - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Err(err). Msg("Error connecting new cached connection.") // Tell the connection cache that the connection is no longer available. @@ -133,7 +134,7 @@ func (c *connectionContainer) connect(ctx context.Context) { return } - c.log.Debug().Str("connectionString", c.connectionString).Msg("Successfully connected new cached connection.") + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)).Msg("Successfully connected new cached connection.") // Inject the real connection into the container. if connection, ok := connection.(tracedPlcConnection); !ok { panic("Return connection doesn't implement the cache.tracedPlcConnection interface") @@ -171,7 +172,7 @@ func (c *connectionContainer) nextWaiter() *connectionRequest { head := c.queue[0] c.queue = c.queue[1:] if head.ctx.Err() != nil { - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Skipping lease request with cancelled context") select { case head.errChan <- head.ctx.Err(): @@ -196,7 +197,7 @@ func (c *connectionContainer) startReconnect(ctx context.Context) { // Close the stale connection so its message-codec workers don't leak. if err := stale.Close(); err != nil { c.log.Debug().Err(err). - Str("connectionString", c.connectionString). + Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Error closing stale connection before reconnect") } } @@ -236,12 +237,12 @@ func (c *connectionContainer) lease(ctx context.Context) (chan *plcConnectionLea break } if c.idleExpired() { - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Dur("maxIdleTime", c.maxIdleTime). Time("idleSince", c.idleSince). Msg("Cached idle connection exceeded max idle time - reconnecting.") } else { - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Cached idle connection is no longer alive - reconnecting.") } c.queue = append(c.queue, connectionRequest{ctx: ctx, connChan: connectionChan, errChan: errorChan}) @@ -254,14 +255,14 @@ func (c *connectionContainer) lease(ctx context.Context) (chan *plcConnectionLea // In this case we don'c need to check for blocks // as the getConnection function of the connection cache // is definitely eagerly waiting for input. - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Got lease instantly as connection was idle.") connectionChan <- connection case StateInUse, StateInitialized: // If the connection is currently busy or not finished initializing, // add the new channel to the queue for this connection. c.queue = append(c.queue, connectionRequest{ctx: ctx, connChan: connectionChan, errChan: errorChan}) - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Int("waiting-queue-size", len(c.queue)). Msg("Added lease-request to queue.") case StateInvalid: @@ -273,7 +274,7 @@ func (c *connectionContainer) lease(ctx context.Context) (chan *plcConnectionLea errorChan <- errors.New("connection container is closed") break } - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Connection is invalid - attempting reconnect.") c.queue = append(c.queue, connectionRequest{ctx: ctx, connChan: connectionChan, errChan: errorChan}) c.startReconnect(ctx) @@ -290,7 +291,7 @@ func (c *connectionContainer) returnConnection(ctx context.Context, newState cac case StateInitialized, StateInvalid: // TODO: Perhaps do a maximum number of retries and then call failConnection() c.log.Debug(). - Str("connectionString", c.connectionString). + Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Stringer("newState", newState). Msg("Client returned a connection, reconnecting.") // Close the stale connection before reconnecting. c.connect() overwrites @@ -306,7 +307,7 @@ func (c *connectionContainer) returnConnection(ctx context.Context, newState cac if stale != nil { if err := stale.Close(); err != nil { c.log.Debug().Err(err). - Str("connectionString", c.connectionString). + Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Error closing stale connection before reconnect") } } @@ -323,7 +324,7 @@ func (c *connectionContainer) returnConnection(ctx context.Context, newState cac // another lease here would lease the same connection twice. return nil default: - c.log.Debug().Str("connectionString", c.connectionString).Msg("Client returned valid connection.") + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)).Msg("Client returned valid connection.") } c.lock.Lock() defer c.lock.Unlock() @@ -344,12 +345,12 @@ func (c *connectionContainer) returnConnection(ctx context.Context, newState cac // as the getConnection function of the connection cache // is definitely eagerly waiting for input. next.connChan <- connection - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Int("waiting-queue-size", len(c.queue)). Msg("Returned connection to the next client waiting.") } else { // Otherwise, just mark the connection as idle. - c.log.Debug().Str("connectionString", c.connectionString). + c.log.Debug().Str("connectionString", spiOptions.RedactConnectionString(c.connectionString)). Msg("Connection set to 'idle'.") c.state = StateIdle c.idleSince = time.Now() @@ -386,5 +387,9 @@ func (c *connectionContainer) idleExpired() bool { } func (c *connectionContainer) String() string { - return fmt.Sprintf("connectionContainer{%s:%s, leaseCounter: %d, closed: %t, state: %s}", c.connectionString, c.connection, c.leaseCounter, c.closed, c.state) + // Redacted here rather than at the call sites: this rendering is reached by any log event + // that carries the container, and one that also logs a redacted connectionString field would + // otherwise print the credential beside it. + return fmt.Sprintf("connectionContainer{%s:%s, leaseCounter: %d, closed: %t, state: %s}", + spiOptions.RedactConnectionString(c.connectionString), c.connection, c.leaseCounter, c.closed, c.state) } diff --git a/plc4go/pom.xml b/plc4go/pom.xml index 212524c8148..6d4ab1d514f 100644 --- a/plc4go/pom.xml +++ b/plc4go/pom.xml @@ -519,6 +519,22 @@ provided + + org.apache.plc4x + plc4x-protocols-slmp + 1.0.0-SNAPSHOT + test-jar + + provided + + + org.apache.plc4x + plc4x-protocols-iec-60870 + 1.0.0-SNAPSHOT + test-jar + + provided + org.apache.plc4x plc4x-protocols-simulated diff --git a/plc4go/spi/options/OptionReader.go b/plc4go/spi/options/OptionReader.go new file mode 100644 index 00000000000..3a91569d89c --- /dev/null +++ b/plc4go/spi/options/OptionReader.go @@ -0,0 +1,253 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package options + +import ( + "sort" + "strings" + "sync" + + "github.com/rs/zerolog" +) + +// ActiveTransportOption is the option under which the driver manager records the transport code +// this connection actually uses ("tcp", "serial", ...). It is stamped by the manager, never +// written by a user - the leading '~' keeps it out of any documented option's namespace - and +// ReportUnknown consumes it to narrow the transport-option exemption to the transport on duty. +const ActiveTransportOption = "~active-transport" + +// OptionReader reads connection-string options and remembers which ones were asked for, so the +// ones nothing asked for can be reported. +// +// Twelve drivers each carried their own copy of the lookup, and none of them could say what had +// been left over: an option nobody read was discarded in silence, which is what turns a typo into +// a setting that appears accepted and does nothing. plc4j reports these +// (DriverBase.warnAboutUnknownParameters); this is how plc4go does. +// +// Recording what was read, rather than declaring what is recognised, is deliberate. A declared +// list is a second description of the same thing and drifts from the code that reads the options; +// a key the driver actually read is recognised by definition. +type OptionReader struct { + log zerolog.Logger + options map[string][]string + consumed map[string]bool + caseInsensitive bool +} + +// NewOptionReader wraps the options of one connection. +func NewOptionReader(log zerolog.Logger, options map[string][]string) *OptionReader { + return &OptionReader{log: log, options: options, consumed: make(map[string]bool, len(options))} +} + +// CaseInsensitive matches option names without regard to case, which the bacnet-ip driver has +// always done - its own lookup lower-cased both sides. The other drivers match exactly, and this +// is opt-in so consolidating the lookups did not quietly widen what any of them accepts. +func (r *OptionReader) CaseInsensitive() *OptionReader { + r.caseInsensitive = true + return r +} + +// Get is the value of an option, or "" when it was not supplied. Asking marks the option as +// consumed, whether or not it was supplied - a driver that asks for an option recognises it. +func (r *OptionReader) Get(key string) string { + r.consumed[key] = true + optionValues, ok := r.options[key] + if !ok && r.caseInsensitive { + for suppliedKey, values := range r.options { + if strings.EqualFold(suppliedKey, key) { + r.consumed[suppliedKey] = true + optionValues, ok = values, true + break + } + } + } + if !ok || len(optionValues) == 0 { + return "" + } + if len(optionValues) > 1 { + r.log.Warn().Str("key", key).Msg("Option must be unique") + } + return optionValues[0] +} + +// Ignore marks options as belonging to someone else - the transport, or the driver's own nested +// parsing - so they are not reported as unknown. The transport options every driver's connection +// string may carry are ignored already; this is for what a particular driver adds. +func (r *OptionReader) Ignore(keys ...string) { + for _, key := range keys { + r.consumed[key] = true + } +} + +// ReportUnknown logs one warning naming every supplied option that nothing read. +// +// It is a warning and never an error: a stray option must not break a connection that would +// otherwise work, which is the rule plc4j settled on for the same report. The operator is told, +// and decides. +func (r *OptionReader) ReportUnknown(protocolCode string) { + activeTransport := "" + if values := r.options[ActiveTransportOption]; len(values) > 0 { + activeTransport = values[0] + } + var unknown, misdirected []string + for key := range r.options { + // The marker is matched without regard to case: c-bus title-cases the shared map's + // keys in place, and the "~Active-Transport" that produces is still not a user option. + if strings.EqualFold(key, ActiveTransportOption) || r.consumed[key] { + continue + } + if isTransportOption(key) { + // A transport's own option is excused; another transport's is misdirected and + // worth its own report - naming the owner beats calling it unknown. An unprefixed + // registered name was injected by a driver, never written by a user, so no + // mismatch with it can be worth reporting. Without the marker (options parsed + // outside a manager connect, discovery among them) the wide exemption stands. + owner := transportPrefixOf(key) + if owner == "" || activeTransport == "" || strings.EqualFold(owner, activeTransport) { + continue + } + misdirected = append(misdirected, key) + continue + } + unknown = append(unknown, key) + } + sort.Strings(unknown) + for _, key := range unknown { + event := r.log.Warn().Str("option", key).Str("driver", protocolCode) + if suggestion := r.suggestionFor(key); suggestion != "" { + event = event.Str("didYouMean", suggestion) + } + event.Msg("Connection string option is not known to this driver and is ignored") + } + sort.Strings(misdirected) + for _, key := range misdirected { + r.log.Warn(). + Str("option", key). + Str("driver", protocolCode). + Str("optionTransport", transportPrefixOf(key)). + Str("activeTransport", activeTransport). + Msg("Connection string option belongs to a transport this connection does not use and is ignored") + } +} + +// transportPrefixOf is the transport code a registered option is addressed under - the part +// before the first '.' - or "" for an unprefixed name a driver injected itself. +func transportPrefixOf(key string) string { + if i := strings.IndexByte(key, '.'); i > 0 { + return key[:i] + } + return "" +} + +// suggestionFor is the option the given unknown one was most likely meant to be, or "" when +// nothing read is close enough to be worth naming. Only options this driver read are candidates, +// so the suggestion cannot point at a name the driver would ignore anyway. +func (r *OptionReader) suggestionFor(unknown string) string { + // Roughly one edit per four characters, so short names do not match everything. The same + // budget plc4j's suggestionFor uses. + budget := len(unknown) / 4 + if budget < 1 { + budget = 1 + } else if budget > 3 { + budget = 3 + } + best, bestDistance := "", budget+1 + for candidate := range r.consumed { + distance := editDistance(unknown, candidate) + if distance > budget { + continue + } + if distance < bestDistance || (distance == bestDistance && candidate < best) { + best, bestDistance = candidate, distance + } + } + return best +} + +// transportOptions are read by the transport layer rather than by a driver, so a driver must not +// report them as unknown. +// +// Each transport registers its own names beside the code that reads them, rather than this file +// carrying one list of every transport's options. A central list is a second description of what +// the transports read and drifts from them - the very thing OptionReader exists to avoid on the +// driver side - and it exempts every name from every transport on every connection, so a serial +// option on a TCP connection would pass unremarked. +// +// A transport that is not linked in registers nothing, and its options are then reported as +// unknown. That is the honest answer: a connection whose transport is not present cannot be +// using them. +// +// The names are registered under the transport's own code ("tcp.connect-timeout-ms"), which is +// how a user addresses them, so an option of one transport is no longer mistaken for an option of +// another - "serial.baud-rate" is not something the TCP transport reads. Which transport is in +// use is known before any configuration is parsed - it is the scheme of the transport URL - and +// the driver manager stamps it into the options as ActiveTransportOption, so ReportUnknown can +// excuse the active transport's options and call out another transport's as misdirected. Only a +// transport instance exists too late for this; its code does not. Options parsed outside a +// manager connect carry no stamp, and the exemption then falls back to every registered name. +var ( + transportOptionsMutex sync.RWMutex + transportOptions = map[string]bool{} +) + +// RegisterTransportOptions records the connection-string options a transport reads. Call it from +// the transport's package initialisation, listing the names that package looks up. +func RegisterTransportOptions(keys ...string) { + transportOptionsMutex.Lock() + defer transportOptionsMutex.Unlock() + for _, key := range keys { + transportOptions[strings.ToLower(key)] = true + } +} + +// IsTransportOption says whether some linked-in transport reads the given option name. Drivers +// using an OptionReader get this applied for them; it is exported for the OPC UA driver, which +// matches option names against its Configuration's fields by reflection rather than by reading +// them one at a time, and so does its own reporting. +func IsTransportOption(key string) bool { + return isTransportOption(key) +} + +func isTransportOption(key string) bool { + transportOptionsMutex.RLock() + defer transportOptionsMutex.RUnlock() + return transportOptions[strings.ToLower(key)] +} + +// editDistance is the Levenshtein distance between two option names. +func editDistance(left, right string) int { + previous := make([]int, len(right)+1) + current := make([]int, len(right)+1) + for j := range previous { + previous[j] = j + } + for i := 1; i <= len(left); i++ { + current[0] = i + for j := 1; j <= len(right); j++ { + substitution := previous[j-1] + if left[i-1] != right[j-1] { + substitution++ + } + current[j] = min(substitution, min(previous[j]+1, current[j-1]+1)) + } + previous, current = current, previous + } + return previous[len(right)] +} diff --git a/plc4go/spi/options/OptionReader_test.go b/plc4go/spi/options/OptionReader_test.go new file mode 100644 index 00000000000..49c9aca34b3 --- /dev/null +++ b/plc4go/spi/options/OptionReader_test.go @@ -0,0 +1,234 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package options + +import ( + "bytes" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" +) + +func readerFor(options map[string][]string) (*OptionReader, *bytes.Buffer) { + var logged bytes.Buffer + return NewOptionReader(zerolog.New(&logged), options), &logged +} + +func TestOptionReader_GetReturnsTheValue(t *testing.T) { + reader, _ := readerFor(map[string][]string{"unit-identifier": {"3"}}) + assert.Equal(t, "3", reader.Get("unit-identifier")) +} + +func TestOptionReader_GetReturnsEmptyForAnAbsentOption(t *testing.T) { + reader, _ := readerFor(map[string][]string{}) + assert.Equal(t, "", reader.Get("unit-identifier")) +} + +// The behaviour of the twelve getFromOptions copies this replaces. +func TestOptionReader_GetWarnsWhenAnOptionIsRepeated(t *testing.T) { + reader, logged := readerFor(map[string][]string{"unit-identifier": {"3", "4"}}) + assert.Equal(t, "3", reader.Get("unit-identifier"), "the first value wins") + assert.Contains(t, logged.String(), "Option must be unique") +} + +// A key the driver read is recognised by definition - that is the whole point of recording +// consumption rather than declaring a list of names. +func TestOptionReader_SaysNothingAboutAnOptionThatWasRead(t *testing.T) { + reader, logged := readerFor(map[string][]string{"unit-identifier": {"3"}}) + reader.Get("unit-identifier") + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +// Asking for an option that was not supplied still counts as recognising it: a driver that reads +// "request-timeout-ms" recognises the name whether or not this connection string carried it. +func TestOptionReader_AskingForAnAbsentOptionStillRecognisesIt(t *testing.T) { + reader, logged := readerFor(map[string][]string{"request-timeout-ms": {"5000"}}) + reader.Get("request-timeout-ms") + reader.Get("unit-identifier") + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +func TestOptionReader_ReportsAnOptionNothingRead(t *testing.T) { + reader, logged := readerFor(map[string][]string{"unit-identifier": {"3"}, "nonsense": {"1"}}) + reader.Get("unit-identifier") + reader.ReportUnknown("modbus-tcp") + + assert.Contains(t, logged.String(), "nonsense") + assert.Contains(t, logged.String(), "modbus-tcp", "the report names the driver") + assert.NotContains(t, logged.String(), "unit-identifier") +} + +// The overwhelmingly common cause is a name that is nearly right, so leading with the replacement +// turns a puzzling no-op into a one-line fix. +func TestOptionReader_SuggestsTheOptionAMisspellingWasMeantToBe(t *testing.T) { + reader, logged := readerFor(map[string][]string{"unit-identifer": {"3"}}) + reader.Get("unit-identifier") + reader.ReportUnknown("modbus-tcp") + + assert.Contains(t, logged.String(), "unit-identifer") + assert.Contains(t, logged.String(), "didYouMean") + assert.Contains(t, logged.String(), "unit-identifier") +} + +func TestOptionReader_SuggestsNothingWhenNothingIsClose(t *testing.T) { + reader, logged := readerFor(map[string][]string{"completely-different": {"1"}}) + reader.Get("unit-identifier") + reader.ReportUnknown("modbus-tcp") + + assert.Contains(t, logged.String(), "completely-different") + assert.NotContains(t, logged.String(), "didYouMean") +} + +// A transport option belongs to another consumer. Reporting it would warn about every connection +// string that sets a timeout, which would teach operators to ignore the warning. +// +// The names are registered here rather than by importing a transport: the transports import this +// package, so a test in it cannot import them back. Each transport registers its own in an init(), +// beside the code that reads them. +func TestOptionReader_SaysNothingAboutTransportOptions(t *testing.T) { + RegisterTransportOptions("connect-timeout-ms", "read-timeout-ms", "reuse-port") + + reader, logged := readerFor(map[string][]string{ + "connect-timeout-ms": {"5000"}, "read-timeout-ms": {"1000"}, "reuse-port": {"true"}, + }) + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +// The exemption covers what some transport registered, and nothing else. An option no transport +// reads is the driver's to report - which is what a hand-kept central list of every transport's +// options could not say. +func TestOptionReader_ReportsAnOptionNoTransportRegistered(t *testing.T) { + RegisterTransportOptions("baud-rate") + + reader, logged := readerFor(map[string][]string{ + "baud-rate": {"9600"}, "baud-rat": {"9600"}, + }) + reader.ReportUnknown("modbus-tcp") + + assert.NotContains(t, logged.String(), `"option":"baud-rate"`) + assert.Contains(t, logged.String(), `"option":"baud-rat"`) +} + +// The manager stamps which transport the connection string selected. With that known, the +// exemption narrows from "every linked transport" to the one on duty: another transport's +// option is doing nothing on this connection, which is exactly what the report exists to say. +func TestOptionReader_ReportsAnotherTransportsOptionWhenTheActiveTransportIsKnown(t *testing.T) { + RegisterTransportOptions("serial.baud-rate", "tcp.connect-timeout-ms") + + reader, logged := readerFor(map[string][]string{ + "serial.baud-rate": {"9600"}, + ActiveTransportOption: {"tcp"}, + }) + reader.ReportUnknown("modbus-tcp") + + assert.Contains(t, logged.String(), `"option":"serial.baud-rate"`) + assert.Contains(t, logged.String(), `"optionTransport":"serial"`, "the report names the transport the option belongs to") + assert.Contains(t, logged.String(), `"activeTransport":"tcp"`, "and the one the connection uses") +} + +func TestOptionReader_SaysNothingAboutTheActiveTransportsOwnOptions(t *testing.T) { + RegisterTransportOptions("tcp.connect-timeout-ms") + + reader, logged := readerFor(map[string][]string{ + "tcp.connect-timeout-ms": {"5000"}, + ActiveTransportOption: {"tcp"}, + }) + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +// A registered name without a transport prefix is one a driver injected into the map itself +// (defaultTcpPort); no user wrote it, so no transport mismatch can be worth reporting. +func TestOptionReader_SaysNothingAboutUnprefixedInjectedOptionsRegardlessOfTransport(t *testing.T) { + RegisterTransportOptions("defaultTcpPort") + + reader, logged := readerFor(map[string][]string{ + "defaultTcpPort": {"502"}, + ActiveTransportOption: {"serial"}, + }) + reader.ReportUnknown("modbus-rtu") + assert.Empty(t, logged.String()) +} + +// The marker is the manager's bookkeeping, not a user option. Matched without regard to case: +// the c-bus driver title-cases every key of the shared map in place, so the marker can reappear +// as "~Active-Transport", and that duplicate is no more a user option than the original. +func TestOptionReader_TheActiveTransportMarkerIsNeverReported(t *testing.T) { + reader, logged := readerFor(map[string][]string{ + ActiveTransportOption: {"tcp"}, "~Active-Transport": {"tcp"}, + }) + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +// Options parsed outside a manager connect - discovery, or a hand-built map - carry no marker. +// The wide exemption then stands: any linked transport's name passes unremarked. +func TestOptionReader_ExemptsEveryRegisteredTransportOptionWithoutTheMarker(t *testing.T) { + RegisterTransportOptions("serial.baud-rate") + + reader, logged := readerFor(map[string][]string{"serial.baud-rate": {"9600"}}) + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +// What a driver parses itself - a nested or prefixed group it handles by hand - is its to claim. +func TestOptionReader_SaysNothingAboutOptionsTheDriverClaims(t *testing.T) { + reader, logged := readerFor(map[string][]string{"browser.depth": {"2"}}) + reader.Ignore("browser.depth") + reader.ReportUnknown("ads") + assert.Empty(t, logged.String()) +} + +func TestOptionReader_SaysNothingAboutAnEmptyOptionMap(t *testing.T) { + reader, logged := readerFor(map[string][]string{}) + reader.ReportUnknown("modbus-tcp") + assert.Empty(t, logged.String()) +} + +// One line per unknown option, in a stable order, so a connection string with several typos +// produces a report that reads the same way twice. +func TestOptionReader_ReportsDeterministically(t *testing.T) { + reader, logged := readerFor(map[string][]string{"zebra": {"1"}, "alpha": {"2"}}) + reader.ReportUnknown("modbus-tcp") + + output := logged.String() + assert.Less(t, bytes.Index([]byte(output), []byte("alpha")), bytes.Index([]byte(output), []byte("zebra"))) +} + +// bacnet-ip has always matched option names without regard to case; consolidating the twelve +// per-driver lookups must not quietly change that, in either direction. +func TestOptionReader_CaseInsensitiveMatchesRegardlessOfCase(t *testing.T) { + reader, logged := readerFor(map[string][]string{"localdeviceid": {"42"}}) + reader.CaseInsensitive() + + assert.Equal(t, "42", reader.Get("LocalDeviceId")) + reader.ReportUnknown("bacnet-ip") + assert.Empty(t, logged.String(), "the supplied spelling counts as consumed") +} + +// The other ten drivers match exactly, as they always did. +func TestOptionReader_MatchesExactlyByDefault(t *testing.T) { + reader, _ := readerFor(map[string][]string{"localdeviceid": {"42"}}) + assert.Equal(t, "", reader.Get("LocalDeviceId")) +} diff --git a/plc4go/spi/options/Redaction.go b/plc4go/spi/options/Redaction.go new file mode 100644 index 00000000000..a1860e9862c --- /dev/null +++ b/plc4go/spi/options/Redaction.go @@ -0,0 +1,72 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package options + +import ( + "net/url" + "regexp" +) + +// RedactConnectionString removes credentials from a connection string before it is logged. +// +// plc4go logged connection strings verbatim, so a password in one reached the log in clear at +// debug level - the driver manager and the connection cache both do it, on every connect. +// +// Which parameters are secret is decided by name here, unlike plc4j, where a @Secret marking on +// the configuration field decides it. plc4go has no equivalent to read at this point: the manager +// logs before any driver has looked at the string, and a driver's options are parsed by hand +// rather than described anywhere this code can see. Rendering a configuration *is* marking-driven +// (the generator's `secret:"true"` tag); this covers the raw string on its way past. +// +// psk-identity is deliberately not matched: it says which key was refused, which is what an +// operator needs when a handshake fails, and hiding it protects nothing. +var ( + secretName = regexp.MustCompile(`(?i)password|passwd|secret|token|psk-key|passphrase`) + // One parameter of a connection string: its separator, its name as written, and its value. + parameter = regexp.MustCompile(`([?&])([^=&]*)=([^&]*)`) + // Credentials in a URI authority have no parameter name to match, so they are removed + // structurally: everything between the first ':' of the userinfo and the '@' that ends it. + // The user segment excludes ':' so that the first colon separates it from the password; were + // it greedy, a password containing a colon would keep everything up to its last one, and + // "bob:pa:ss@" would redact to "bob:pa:******@" - half the credential, published. + userinfo = regexp.MustCompile(`(//[^/@\s:]*:)([^/@\s]*)(@)`) +) + +// Redacted is what a removed value is replaced with. +const Redacted = "******" + +// RedactConnectionString returns the connection string with every credential replaced. +func RedactConnectionString(connectionString string) string { + redacted := userinfo.ReplaceAllString(connectionString, "${1}"+Redacted+"${3}") + return parameter.ReplaceAllStringFunc(redacted, func(match string) string { + parts := parameter.FindStringSubmatch(match) + // Decided from the decoded name, not the name as written: "?%70assword=hunter2" is the + // password parameter by the time url.Values has decoded it and a driver reads it, but no + // pattern over the raw string sees the word. The value is a credential either way. + name := parts[2] + if decoded, err := url.QueryUnescape(name); err == nil { + name = decoded + } + if !secretName.MatchString(name) { + return match + } + return parts[1] + parts[2] + "=" + Redacted + }) +} diff --git a/plc4go/spi/options/Redaction_test.go b/plc4go/spi/options/Redaction_test.go new file mode 100644 index 00000000000..23806aa990f --- /dev/null +++ b/plc4go/spi/options/Redaction_test.go @@ -0,0 +1,79 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package options + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestRedactConnectionString(t *testing.T) { + for _, c := range []struct { + name string + given string + want string + }{ + {"a password parameter", "opcua://host?username=op&password=hunter2", + "opcua://host?username=op&password=******"}, + {"a leading password parameter", "opcua://host?password=hunter2&username=op", + "opcua://host?password=******&username=op"}, + {"a pre-shared key", "s7:tls-psk://host?tls-psk.psk-key=0011deadbeef", + "s7:tls-psk://host?tls-psk.psk-key=******"}, + {"a keystore password", "opcua://host?keyStorePassword=abc", + "opcua://host?keyStorePassword=******"}, + {"regardless of case", "opcua://host?PassWord=hunter2", + "opcua://host?PassWord=******"}, + {"credentials in the authority, which have no parameter name", + "s7://operator:hunter2@plc:102", "s7://operator:******@plc:102"}, + // A colon is legal inside a password. The user segment stops at the first colon so the + // rest of the credential is the password; a greedy one would publish "operator:hun". + {"a password containing a colon", "s7://operator:hun:ter2@plc:102", + "s7://operator:******@plc:102"}, + {"both at once", "opcua://op:hunter2@host?password=abc&read-timeout-ms=5000", + "opcua://op:******@host?password=******&read-timeout-ms=5000"}, + {"nothing to redact", "modbus-tcp://host:502?unit-identifier=1", + "modbus-tcp://host:502?unit-identifier=1"}, + {"no parameters at all", "modbus-tcp://host:502", "modbus-tcp://host:502"}, + {"empty", "", ""}, + // The identity names which key was refused - the one thing an operator needs when a PSK + // handshake fails. Hiding it costs the diagnosis and protects nothing. + {"the psk identity is not a secret", "s7:tls-psk://host?tls-psk.psk-identity=plc4x", + "s7:tls-psk://host?tls-psk.psk-identity=plc4x"}, + // Masking these would cost the diagnosis: a path, a store type, a boolean. + {"things shaped like keys that are not keys", + "opcua://host?keyStoreFile=/etc/client.p12&securityPolicy=None&discovery=false", + "opcua://host?keyStoreFile=/etc/client.p12&securityPolicy=None&discovery=false"}, + // Names are matched, not values: a username whose value happens to contain "secret" is + // still a username, and masking it would cost an operator the account name. + {"a name that merely looks similar", "opcua://host?username=secretive-bob", + "opcua://host?username=secretive-bob"}, + // url.Values decodes the name before a driver reads it, so this is the password parameter + // however it was written. Matching the raw string alone would log the value untouched. + {"a percent-encoded name", "opcua://host?%70assword=hunter2", + "opcua://host?%70assword=******"}, + {"a percent-encoded name among others", "opcua://host?a=1&pass%77ord=hunter2&b=2", + "opcua://host?a=1&pass%77ord=******&b=2"}, + } { + t.Run(c.name, func(t *testing.T) { + assert.Equal(t, c.want, RedactConnectionString(c.given)) + }) + } +} diff --git a/plc4go/spi/transports/pcap/Transport.go b/plc4go/spi/transports/pcap/Transport.go index 868c1f748ea..602a29f6c65 100644 --- a/plc4go/spi/transports/pcap/Transport.go +++ b/plc4go/spi/transports/pcap/Transport.go @@ -61,15 +61,15 @@ func (m *Transport) GetTransportName() string { func (m *Transport) CreateTransportInstance(transportUrl url.URL, options map[string][]string, _options ...options.WithOption) (transports.TransportInstance, error) { var transportType = PCAP - if val, ok := options["transport-type"]; ok { + if val, ok := options["pcap.transport-type"]; ok { transportType = TransportType(val[0]) } var portRange = "" - if val, ok := options["transport-port-range"]; ok { + if val, ok := options["pcap.transport-port-range"]; ok { portRange = val[0] } var speedFactor float32 = 1.0 - if val, ok := options["speed-factor"]; ok { + if val, ok := options["pcap.speed-factor"]; ok { if parsedSpeedFactory, err := strconv.ParseFloat(val[0], 32); err != nil { return nil, errors.Wrap(err, "error parsing speed-factor") } else { diff --git a/plc4go/spi/transports/pcap/Transport_test.go b/plc4go/spi/transports/pcap/Transport_test.go index 8519c801d0f..bb649c21c7a 100644 --- a/plc4go/spi/transports/pcap/Transport_test.go +++ b/plc4go/spi/transports/pcap/Transport_test.go @@ -73,9 +73,9 @@ func TestTransport_CreateTransportInstance(t *testing.T) { name: "create it", args: args{ options: map[string][]string{ - "transport-type": {"pcap"}, - "transport-port-range": {"1-3"}, - "speed-factor": {"1.5"}, + "pcap.transport-type": {"pcap"}, + "pcap.transport-port-range": {"1-3"}, + "pcap.speed-factor": {"1.5"}, }, }, want: func() transports.TransportInstance { diff --git a/plc4go/spi/transports/pcap/options_registration.go b/plc4go/spi/transports/pcap/options_registration.go new file mode 100644 index 00000000000..ab7ee86019c --- /dev/null +++ b/plc4go/spi/transports/pcap/options_registration.go @@ -0,0 +1,34 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package pcap + +import "github.com/apache/plc4x/plc4go/spi/options" + +// The options this transport reads, declared beside the code that reads them so a driver's report +// of unread connection-string options does not name them. +// +// A user addresses a transport's option under the transport's own code, as PLC4J does and as the +// documentation says: "tcp.connect-timeout-ms", not "connect-timeout-ms". An option a driver +// injects into the map itself is not addressed by anyone and carries no prefix. +func init() { + options.RegisterTransportOptions( + "pcap.transport-type", "pcap.transport-port-range", "pcap.speed-factor", + ) +} diff --git a/plc4go/spi/transports/serial/TransportInstance_pty_test.go b/plc4go/spi/transports/serial/TransportInstance_pty_test.go index a601d13f146..65de244127c 100644 --- a/plc4go/spi/transports/serial/TransportInstance_pty_test.go +++ b/plc4go/spi/transports/serial/TransportInstance_pty_test.go @@ -116,10 +116,10 @@ func TestTransportInstance_OptionsAppliedOnPTY(t *testing.T) { instance, err := transport.CreateTransportInstance( url.URL{Scheme: "serial", Path: slavePath}, map[string][]string{ - "data-bits": {"7"}, - "parity": {"EVEN"}, // deliberately non-canonical case - "stop-bits": {"2"}, - "dtr": {"true"}, // must warn, not fail, on a pty + "serial.data-bits": {"7"}, + "serial.parity": {"EVEN"}, // deliberately non-canonical case + "serial.stop-bits": {"2"}, + "serial.dtr": {"true"}, // must warn, not fail, on a pty }, ) require.NoError(t, err) @@ -140,7 +140,7 @@ func TestTransportInstance_FallbackReadDeadlineBoundsSilentRead(t *testing.T) { transport := NewTransport() instance, err := transport.CreateTransportInstance( url.URL{Scheme: "serial", Path: slavePath}, - map[string][]string{"read-timeout": {"200"}}, + map[string][]string{"serial.read-timeout-ms": {"200"}}, ) require.NoError(t, err) require.NoError(t, instance.Connect(context.Background())) @@ -160,7 +160,7 @@ func TestTransportInstance_ExplicitCtxDeadlineBeatsFallback(t *testing.T) { transport := NewTransport() instance, err := transport.CreateTransportInstance( url.URL{Scheme: "serial", Path: slavePath}, - map[string][]string{"read-timeout": {"60000"}}, + map[string][]string{"serial.read-timeout-ms": {"60000"}}, ) require.NoError(t, err) require.NoError(t, instance.Connect(context.Background())) @@ -179,7 +179,7 @@ func TestTransportInstance_ExplicitCtxDeadlineBeatsFallback(t *testing.T) { func TestTransportInstance_ReusePortSharesOnePTY(t *testing.T) { master, slavePath := openPTY(t) transport := NewTransport() - options := map[string][]string{"reuse-port": {"true"}} + options := map[string][]string{"serial.reuse-port": {"true"}} first, err := transport.CreateTransportInstance(url.URL{Scheme: "serial", Path: slavePath}, options) require.NoError(t, err) @@ -208,13 +208,13 @@ func TestTransportInstance_ReusePortConfigMismatch(t *testing.T) { transport := NewTransport() first, err := transport.CreateTransportInstance(url.URL{Scheme: "serial", Path: slavePath}, - map[string][]string{"reuse-port": {"true"}, "baud-rate": {"9600"}}) + map[string][]string{"serial.reuse-port": {"true"}, "serial.baud-rate": {"9600"}}) require.NoError(t, err) require.NoError(t, first.Connect(context.Background())) t.Cleanup(func() { _ = first.Close() }) second, err := transport.CreateTransportInstance(url.URL{Scheme: "serial", Path: slavePath}, - map[string][]string{"reuse-port": {"true"}, "baud-rate": {"19200"}}) + map[string][]string{"serial.reuse-port": {"true"}, "serial.baud-rate": {"19200"}}) require.NoError(t, err) err = second.Connect(context.Background()) require.Error(t, err) @@ -225,7 +225,7 @@ func TestTransportInstance_DedicatedInterframeDelayOnPTY(t *testing.T) { master, slavePath := openPTY(t) transport := NewTransport() instance, err := transport.CreateTransportInstance(url.URL{Scheme: "serial", Path: slavePath}, - map[string][]string{"interframe-delay": {"60"}}) + map[string][]string{"serial.interframe-delay": {"60"}}) require.NoError(t, err) require.NoError(t, instance.Connect(context.Background())) t.Cleanup(func() { _ = instance.Close() }) diff --git a/plc4go/spi/transports/serial/TransportInstance_test.go b/plc4go/spi/transports/serial/TransportInstance_test.go index 808da7105e0..0ecab2bdbe1 100644 --- a/plc4go/spi/transports/serial/TransportInstance_test.go +++ b/plc4go/spi/transports/serial/TransportInstance_test.go @@ -304,11 +304,11 @@ func TestParseAndCreate_OptionsReachInstance(t *testing.T) { instance, err := transport.CreateTransportInstance( url.URL{Scheme: "serial", Path: "/dev/ttyTest0"}, map[string][]string{ - "baud-rate": {"19200"}, - "data-bits": {"7"}, - "parity": {"even"}, - "stop-bits": {"2"}, - "read-timeout": {"250"}, + "serial.baud-rate": {"19200"}, + "serial.data-bits": {"7"}, + "serial.parity": {"even"}, + "serial.stop-bits": {"2"}, + "serial.read-timeout-ms": {"250"}, }, ) require.NoError(t, err) @@ -326,10 +326,10 @@ func TestParseAndCreate_InvalidOptionFailsFast(t *testing.T) { transport := NewTransport() _, err := transport.CreateTransportInstance( url.URL{Scheme: "serial", Path: "/dev/ttyTest0"}, - map[string][]string{"parity": {"strong"}}, + map[string][]string{"serial.parity": {"strong"}}, ) require.Error(t, err) - assert.Contains(t, err.Error(), `"parity"`) + assert.Contains(t, err.Error(), `"serial.parity"`) } func TestWrite_FallbackWriteDeadline(t *testing.T) { diff --git a/plc4go/spi/transports/serial/options.go b/plc4go/spi/transports/serial/options.go index dc44ceaf542..3dc86cb95a0 100644 --- a/plc4go/spi/transports/serial/options.go +++ b/plc4go/spi/transports/serial/options.go @@ -67,31 +67,31 @@ func defaultSerialConfig() serialConfig { func parseSerialOptions(options map[string][]string) (serialConfig, error) { cfg := defaultSerialConfig() - if raw, ok := firstValue(options, "baud-rate"); ok { + if raw, ok := firstValue(options, "serial.baud-rate"); ok { value, err := strconv.ParseUint(raw, 10, 32) if err != nil || value == 0 { - return cfg, optionError("baud-rate", raw, "must be a positive integer") + return cfg, optionError("serial.baud-rate", raw, "must be a positive integer") } cfg.port.BaudRate = uint(value) } - if raw, ok := firstValue(options, "data-bits"); ok { + if raw, ok := firstValue(options, "serial.data-bits"); ok { value, err := strconv.ParseUint(raw, 10, 8) if err != nil || value < 5 || value > 8 { - return cfg, optionError("data-bits", raw, "must be 5..8") + return cfg, optionError("serial.data-bits", raw, "must be 5..8") } cfg.port.DataBits = uint(value) } - if raw, ok := firstValue(options, "stop-bits"); ok { + if raw, ok := firstValue(options, "serial.stop-bits"); ok { switch raw { case "1": cfg.port.StopBits = serialport.StopBitsOne case "2": cfg.port.StopBits = serialport.StopBitsTwo default: - return cfg, optionError("stop-bits", raw, "must be 1 or 2") + return cfg, optionError("serial.stop-bits", raw, "must be 1 or 2") } } - if raw, ok := firstValue(options, "parity"); ok { + if raw, ok := firstValue(options, "serial.parity"); ok { switch normalizeEnum(raw) { case "none": cfg.port.Parity = serialport.ParityNone @@ -104,10 +104,10 @@ func parseSerialOptions(options map[string][]string) (serialConfig, error) { case "space": cfg.port.Parity = serialport.ParitySpace default: - return cfg, optionError("parity", raw, "must be one of none, odd, even, mark, space") + return cfg, optionError("serial.parity", raw, "must be one of none, odd, even, mark, space") } } - if raw, ok := firstValue(options, "flow-control"); ok { + if raw, ok := firstValue(options, "serial.flow-control"); ok { switch normalizeEnum(raw) { case "none": // defaults already off @@ -116,55 +116,55 @@ func parseSerialOptions(options map[string][]string) (serialConfig, error) { case "xon-xoff": cfg.port.XONXOFFFlowControl = true default: - return cfg, optionError("flow-control", raw, "must be one of none, rts-cts, xon-xoff") + return cfg, optionError("serial.flow-control", raw, "must be one of none, rts-cts, xon-xoff") } } - if raw, ok := firstValue(options, "dtr"); ok { + if raw, ok := firstValue(options, "serial.dtr"); ok { value, err := strconv.ParseBool(raw) if err != nil { - return cfg, optionError("dtr", raw, "must be true or false") + return cfg, optionError("serial.dtr", raw, "must be true or false") } cfg.dtr = value } - if raw, ok := firstValue(options, "rts"); ok { + if raw, ok := firstValue(options, "serial.rts"); ok { value, err := strconv.ParseBool(raw) if err != nil { - return cfg, optionError("rts", raw, "must be true or false") + return cfg, optionError("serial.rts", raw, "must be true or false") } cfg.rts = value } - if raw, ok := firstValue(options, "read-timeout"); ok { + if raw, ok := firstValue(options, "serial.read-timeout-ms"); ok { millis, err := strconv.ParseUint(raw, 10, 32) if err != nil { - return cfg, optionError("read-timeout", raw, "must be a non-negative integer (milliseconds)") + return cfg, optionError("serial.read-timeout-ms", raw, "must be a non-negative integer (milliseconds)") } cfg.readTimeout = time.Duration(millis) * time.Millisecond } - if raw, ok := firstValue(options, "write-timeout"); ok { + if raw, ok := firstValue(options, "serial.write-timeout-ms"); ok { millis, err := strconv.ParseUint(raw, 10, 32) if err != nil { - return cfg, optionError("write-timeout", raw, "must be a non-negative integer (milliseconds)") + return cfg, optionError("serial.write-timeout-ms", raw, "must be a non-negative integer (milliseconds)") } cfg.writeTimeout = time.Duration(millis) * time.Millisecond } - if raw, ok := firstValue(options, "connect-timeout"); ok { + if raw, ok := firstValue(options, "serial.connect-timeout-ms"); ok { millis, err := strconv.ParseUint(raw, 10, 32) if err != nil { - return cfg, optionError("connect-timeout", raw, "must be a non-negative integer (milliseconds)") + return cfg, optionError("serial.connect-timeout-ms", raw, "must be a non-negative integer (milliseconds)") } cfg.connectTimeout = uint32(millis) } - if raw, ok := firstValue(options, "reuse-port"); ok { + if raw, ok := firstValue(options, "serial.reuse-port"); ok { value, err := strconv.ParseBool(raw) if err != nil { - return cfg, optionError("reuse-port", raw, "must be true or false") + return cfg, optionError("serial.reuse-port", raw, "must be true or false") } cfg.reusePort = value } - if raw, ok := firstValue(options, "interframe-delay"); ok { + if raw, ok := firstValue(options, "serial.interframe-delay"); ok { millis, err := strconv.ParseUint(raw, 10, 32) if err != nil { - return cfg, optionError("interframe-delay", raw, "must be a non-negative integer (milliseconds)") + return cfg, optionError("serial.interframe-delay", raw, "must be a non-negative integer (milliseconds)") } cfg.interframeDelay = time.Duration(millis) * time.Millisecond } diff --git a/plc4go/spi/transports/serial/options_registration.go b/plc4go/spi/transports/serial/options_registration.go new file mode 100644 index 00000000000..812242f5ec0 --- /dev/null +++ b/plc4go/spi/transports/serial/options_registration.go @@ -0,0 +1,37 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package serial + +import "github.com/apache/plc4x/plc4go/spi/options" + +// The options this transport reads, declared beside the code that reads them so a driver's report +// of unread connection-string options does not name them. +// +// A user addresses a transport's option under the transport's own code, as PLC4J does and as the +// documentation says: "tcp.connect-timeout-ms", not "connect-timeout-ms". An option a driver +// injects into the map itself is not addressed by anyone and carries no prefix. +func init() { + options.RegisterTransportOptions( + "serial.baud-rate", "serial.data-bits", "serial.stop-bits", "serial.parity", + "serial.flow-control", "serial.dtr", "serial.rts", "serial.read-timeout-ms", + "serial.write-timeout-ms", "serial.connect-timeout-ms", "serial.reuse-port", + "serial.interframe-delay", + ) +} diff --git a/plc4go/spi/transports/serial/options_test.go b/plc4go/spi/transports/serial/options_test.go index b0a7afb6067..3e2810c8f07 100644 --- a/plc4go/spi/transports/serial/options_test.go +++ b/plc4go/spi/transports/serial/options_test.go @@ -44,16 +44,16 @@ func TestParseSerialOptions(t *testing.T) { { name: "full happy path", options: map[string][]string{ - "baud-rate": {"19200"}, - "data-bits": {"7"}, - "stop-bits": {"2"}, - "parity": {"even"}, - "flow-control": {"rts-cts"}, - "dtr": {"true"}, - "rts": {"true"}, - "read-timeout": {"250"}, - "write-timeout": {"0"}, - "connect-timeout": {"5000"}, + "serial.baud-rate": {"19200"}, + "serial.data-bits": {"7"}, + "serial.stop-bits": {"2"}, + "serial.parity": {"even"}, + "serial.flow-control": {"rts-cts"}, + "serial.dtr": {"true"}, + "serial.rts": {"true"}, + "serial.read-timeout-ms": {"250"}, + "serial.write-timeout-ms": {"0"}, + "serial.connect-timeout-ms": {"5000"}, }, want: serialConfig{ port: serialport.Config{ @@ -68,7 +68,7 @@ func TestParseSerialOptions(t *testing.T) { }, { name: "enum values are case-insensitive and accept underscores", - options: map[string][]string{"parity": {"EVEN"}, "flow-control": {"XON_XOFF"}}, + options: map[string][]string{"serial.parity": {"EVEN"}, "serial.flow-control": {"XON_XOFF"}}, want: func() serialConfig { c := defaultSerialConfig() c.port.Parity = serialport.ParityEven @@ -78,7 +78,7 @@ func TestParseSerialOptions(t *testing.T) { }, { name: "mark and space parity", - options: map[string][]string{"parity": {"Mark"}}, + options: map[string][]string{"serial.parity": {"Mark"}}, want: func() serialConfig { c := defaultSerialConfig() c.port.Parity = serialport.ParityMark @@ -86,8 +86,8 @@ func TestParseSerialOptions(t *testing.T) { }(), }, { - name: "read-timeout zero means blocking", - options: map[string][]string{"read-timeout": {"0"}}, + name: "read-timeout-ms zero means blocking", + options: map[string][]string{"serial.read-timeout-ms": {"0"}}, want: func() serialConfig { c := defaultSerialConfig() c.readTimeout = 0 @@ -96,47 +96,47 @@ func TestParseSerialOptions(t *testing.T) { }, { name: "invalid baud rate", - options: map[string][]string{"baud-rate": {"fast"}}, - wantErr: `"baud-rate"`, + options: map[string][]string{"serial.baud-rate": {"fast"}}, + wantErr: `"serial.baud-rate"`, }, { name: "zero baud rate rejected", - options: map[string][]string{"baud-rate": {"0"}}, - wantErr: `"baud-rate"`, + options: map[string][]string{"serial.baud-rate": {"0"}}, + wantErr: `"serial.baud-rate"`, }, { name: "data-bits out of range", - options: map[string][]string{"data-bits": {"9"}}, - wantErr: `"data-bits"`, + options: map[string][]string{"serial.data-bits": {"9"}}, + wantErr: `"serial.data-bits"`, }, { name: "stop-bits out of range", - options: map[string][]string{"stop-bits": {"3"}}, - wantErr: `"stop-bits"`, + options: map[string][]string{"serial.stop-bits": {"3"}}, + wantErr: `"serial.stop-bits"`, }, { name: "unknown parity value", - options: map[string][]string{"parity": {"strong"}}, - wantErr: `"parity"`, + options: map[string][]string{"serial.parity": {"strong"}}, + wantErr: `"serial.parity"`, }, { name: "combined flow control value rejected", - options: map[string][]string{"flow-control": {"rts-cts-xon-xoff"}}, - wantErr: `"flow-control"`, + options: map[string][]string{"serial.flow-control": {"rts-cts-xon-xoff"}}, + wantErr: `"serial.flow-control"`, }, { name: "invalid dtr boolean", - options: map[string][]string{"dtr": {"yes-please"}}, - wantErr: `"dtr"`, + options: map[string][]string{"serial.dtr": {"yes-please"}}, + wantErr: `"serial.dtr"`, }, { - name: "invalid read-timeout", - options: map[string][]string{"read-timeout": {"-5"}}, - wantErr: `"read-timeout"`, + name: "invalid read-timeout-ms", + options: map[string][]string{"serial.read-timeout-ms": {"-5"}}, + wantErr: `"serial.read-timeout-ms"`, }, { name: "empty value slice ignored like absent option", - options: map[string][]string{"parity": {}}, + options: map[string][]string{"serial.parity": {}}, want: defaultSerialConfig(), }, { @@ -146,7 +146,7 @@ func TestParseSerialOptions(t *testing.T) { }, { name: "reuse-port accepted", - options: map[string][]string{"reuse-port": {"true"}}, + options: map[string][]string{"serial.reuse-port": {"true"}}, want: func() serialConfig { c := defaultSerialConfig() c.reusePort = true @@ -155,7 +155,7 @@ func TestParseSerialOptions(t *testing.T) { }, { name: "interframe-delay accepted", - options: map[string][]string{"interframe-delay": {"50"}}, + options: map[string][]string{"serial.interframe-delay": {"50"}}, want: func() serialConfig { c := defaultSerialConfig() c.interframeDelay = 50 * time.Millisecond @@ -164,13 +164,13 @@ func TestParseSerialOptions(t *testing.T) { }, { name: "invalid reuse-port", - options: map[string][]string{"reuse-port": {"maybe"}}, - wantErr: `"reuse-port"`, + options: map[string][]string{"serial.reuse-port": {"maybe"}}, + wantErr: `"serial.reuse-port"`, }, { name: "invalid interframe-delay", - options: map[string][]string{"interframe-delay": {"-1"}}, - wantErr: `"interframe-delay"`, + options: map[string][]string{"serial.interframe-delay": {"-1"}}, + wantErr: `"serial.interframe-delay"`, }, } for _, tt := range tests { diff --git a/plc4go/spi/transports/tcp/Transport.go b/plc4go/spi/transports/tcp/Transport.go index 7ce0239bea4..d306ccb9092 100644 --- a/plc4go/spi/transports/tcp/Transport.go +++ b/plc4go/spi/transports/tcp/Transport.go @@ -78,10 +78,10 @@ func (m *Transport) CreateTransportInstance(transportUrl url.URL, options map[st } } var connectTimeout uint32 = 1000 - if val, ok := options["connect-timeout"]; ok { + if val, ok := options["tcp.connect-timeout-ms"]; ok { parsedConnectTimeout, err := strconv.ParseUint(val[0], 10, 32) if err != nil { - return nil, errors.Wrap(err, "error setting connect-timeout") + return nil, errors.Wrap(err, "error setting tcp.connect-timeout-ms") } connectTimeout = uint32(parsedConnectTimeout) } diff --git a/plc4go/spi/transports/tcp/TransportInstanceDeadline_test.go b/plc4go/spi/transports/tcp/TransportInstanceDeadline_test.go index 70f2a7684d9..395de0e314b 100644 --- a/plc4go/spi/transports/tcp/TransportInstanceDeadline_test.go +++ b/plc4go/spi/transports/tcp/TransportInstanceDeadline_test.go @@ -77,7 +77,7 @@ func TestTransportInstance_WriteHonorsContextDeadline(t *testing.T) { // A single large write is not guaranteed to block everywhere: Windows' // loopback fast path ignores post-connect buffer sizes and absorbs even a // 64MB payload before a 200ms deadline. Write in a loop instead (like Go's - // own net write-timeout tests): on Linux/macOS the first write blocks and + // own net write-timeout-ms tests): on Linux/macOS the first write blocks and // is interrupted mid-flight, on Windows either an in-flight send is // cancelled at the deadline or the next write fails instantly - both must // surface a timeout error. diff --git a/plc4go/spi/transports/tcp/Transport_test.go b/plc4go/spi/transports/tcp/Transport_test.go index 9b797da7ded..daf96821e7d 100644 --- a/plc4go/spi/transports/tcp/Transport_test.go +++ b/plc4go/spi/transports/tcp/Transport_test.go @@ -165,8 +165,8 @@ func TestTransport_CreateTransportInstance(t *testing.T) { args: args{ transportUrl: url.URL{Host: "127.0.0.1"}, options: map[string][]string{ - "defaultTcpPort": {"123"}, - "connect-timeout": {"123"}, + "defaultTcpPort": {"123"}, + "tcp.connect-timeout-ms": {"123"}, }, }, want: func() transports.TransportInstance { @@ -187,8 +187,8 @@ func TestTransport_CreateTransportInstance(t *testing.T) { args: args{ transportUrl: url.URL{Host: "127.0.0.1"}, options: map[string][]string{ - "defaultTcpPort": {"123"}, - "connect-timeout": {"banana"}, + "defaultTcpPort": {"123"}, + "tcp.connect-timeout-ms": {"banana"}, }, }, wantErr: true, diff --git a/plc4go/spi/transports/tcp/options_registration.go b/plc4go/spi/transports/tcp/options_registration.go new file mode 100644 index 00000000000..053fdc2b5a1 --- /dev/null +++ b/plc4go/spi/transports/tcp/options_registration.go @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package tcp + +import "github.com/apache/plc4x/plc4go/spi/options" + +// The options this transport reads, declared beside the code that reads them so a driver's report +// of unread connection-string options does not name them. +// +// A user addresses a transport's option under the transport's own code, as PLC4J does and as the +// documentation says: "tcp.connect-timeout-ms", not "connect-timeout-ms". An option a driver +// injects into the map itself is not addressed by anyone and carries no prefix. +func init() { + options.RegisterTransportOptions( + "tcp.connect-timeout-ms", + // Injected by a driver rather than written by a user, so it carries no prefix. + "defaultTcpPort", + ) +} diff --git a/plc4go/spi/transports/test/options_registration.go b/plc4go/spi/transports/test/options_registration.go new file mode 100644 index 00000000000..8669d9c155c --- /dev/null +++ b/plc4go/spi/transports/test/options_registration.go @@ -0,0 +1,35 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package test + +import "github.com/apache/plc4x/plc4go/spi/options" + +// The options this transport reads, declared beside the code that reads them so a driver's report +// of unread connection-string options does not name them. +// +// A user addresses a transport's option under the transport's own code, as PLC4J does and as the +// documentation says: "tcp.connect-timeout-ms", not "connect-timeout-ms". An option a driver +// injects into the map itself is not addressed by anyone and carries no prefix. +func init() { + options.RegisterTransportOptions( + // Injected by the test harness rather than written by a user, so they carry no prefix. + "failTestTransport", "simulatedLatency", + ) +} diff --git a/plc4go/spi/transports/udp/Transport.go b/plc4go/spi/transports/udp/Transport.go index 4f2da85e5da..fa5ecc559ec 100644 --- a/plc4go/spi/transports/udp/Transport.go +++ b/plc4go/spi/transports/udp/Transport.go @@ -89,7 +89,7 @@ func (m *Transport) CreateTransportInstanceForLocalAddress(transportUrl url.URL, } var soReUse bool - if val, ok := options["so-reuse"]; ok { + if val, ok := options["udp.so-reuse"]; ok { if parseBool, err := strconv.ParseBool(val[0]); err != nil { return nil, errors.Wrap(err, "error setting so-reuse") } else { diff --git a/plc4go/spi/transports/udp/Transport_test.go b/plc4go/spi/transports/udp/Transport_test.go index 8e86786dff9..ebb1c20e1f6 100644 --- a/plc4go/spi/transports/udp/Transport_test.go +++ b/plc4go/spi/transports/udp/Transport_test.go @@ -195,7 +195,7 @@ func TestTransport_CreateTransportInstanceForLocalAddress(t *testing.T) { transportUrl: url.URL{Host: "127.0.0.1"}, options: map[string][]string{ "defaultUdpPort": {"123"}, - "so-reuse": {"true"}, + "udp.so-reuse": {"true"}, }, }, want: func() transports.TransportInstance { @@ -216,7 +216,7 @@ func TestTransport_CreateTransportInstanceForLocalAddress(t *testing.T) { transportUrl: url.URL{Host: "127.0.0.1"}, options: map[string][]string{ "defaultUdpPort": {"123"}, - "so-reuse": {"banana"}, + "udp.so-reuse": {"banana"}, }, }, wantErr: true, diff --git a/plc4go/spi/transports/udp/options_registration.go b/plc4go/spi/transports/udp/options_registration.go new file mode 100644 index 00000000000..10140c0b316 --- /dev/null +++ b/plc4go/spi/transports/udp/options_registration.go @@ -0,0 +1,36 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +package udp + +import "github.com/apache/plc4x/plc4go/spi/options" + +// The options this transport reads, declared beside the code that reads them so a driver's report +// of unread connection-string options does not name them. +// +// A user addresses a transport's option under the transport's own code, as PLC4J does and as the +// documentation says: "tcp.connect-timeout-ms", not "connect-timeout-ms". An option a driver +// injects into the map itself is not addressed by anyone and carries no prefix. +func init() { + options.RegisterTransportOptions( + "udp.so-reuse", + // Injected by a driver rather than written by a user, so it carries no prefix. + "defaultUdpPort", + ) +} diff --git a/plc4go/tests/configparity/parameter_names_test.go b/plc4go/tests/configparity/parameter_names_test.go new file mode 100644 index 00000000000..a6c622bc87f --- /dev/null +++ b/plc4go/tests/configparity/parameter_names_test.go @@ -0,0 +1,133 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ + +// Package configparity checks that a connection string means the same thing here as it does in +// PLC4J. +// +// **What this can and cannot do.** The two bindings share a specification, not a runtime: PLC4J +// resolves a connection string through ConfigurationFactory inside a JVM, PLC4Go parses it by +// hand in a Go binary, and no test process holds both. So parity is asserted by *shared cases* +// rather than by execution - the expectations below are the ones PLC4J's own tests assert for the +// same string, and the Java counterpart of this file names it in a comment so the pair is +// findable. Two copies of one table can drift; if that ever bites, the stronger form is a single +// checked-in fixture both bindings read as test data. +// +// This exists because the drift is real. PLC4Go's modbus driver accepted "unit-identifier" while +// PLC4J declared only "default-unit-identifier", so one connection string set the unit here and +// was silently ignored there - for as long as anyone had been reading the Go getting-started +// page, which documented exactly that string. +package configparity + +import ( + "bytes" + "testing" + + "github.com/rs/zerolog" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/apache/plc4x/plc4go/internal/modbus" + "github.com/apache/plc4x/plc4go/internal/s7" +) + +type parseOptions func(zerolog.Logger, map[string][]string) (interface{}, error) + +// parseReporting runs a driver's option parsing and returns whatever it logged, which is where an +// unrecognised name is reported. +func parseReporting(t *testing.T, parse func(zerolog.Logger, map[string][]string) error, + options map[string][]string) string { + t.Helper() + var logged bytes.Buffer + require.NoError(t, parse(zerolog.New(&logged), options)) + return logged.String() +} + +func modbusParse(log zerolog.Logger, options map[string][]string) error { + _, err := modbus.ParseFromOptions(log, options) + return err +} + +func s7Parse(log zerolog.Logger, options map[string][]string) error { + _, err := s7.ParseFromOptions(log, options) + return err +} + +// A canonical name is read by the driver, so nothing reports it. This is the property that makes +// one connection string mean one thing: PLC4J declares these names, and PLC4Go reads them. +func TestTheCanonicalNamesAreRecognisedHere(t *testing.T) { + logged := parseReporting(t, modbusParse, map[string][]string{ + "default-unit-identifier": {"3"}, + "request-timeout-ms": {"5000"}, + "default-payload-byte-order": {"LITTLE_ENDIAN"}, + }) + assert.NotContains(t, logged, "not known", "every canonical modbus name must be read here") + + // s7, for a setting both bindings implement. Not every PLC4J parameter has a counterpart + // here - PLC4Go's s7 has no S7H dual-path, so it reads no "ha-*" name, and reports one as + // unknown, which is the truth: setting it here would do nothing. Parity is that a capability + // *both* have is spelled the same, not that the capability sets match. + logged = parseReporting(t, s7Parse, map[string][]string{ + "controller-type": {"S7_1200"}, + "cotp.remote-rack": {"0"}, + "cotp.remote-slot": {"1"}, + "pdu-size": {"1024"}, + }) + assert.NotContains(t, logged, "not known", "the s7 settings both bindings implement") +} + +// The rack and slot carry the "cotp." prefix in PLC4J, which declares them on the COTP transport's +// configuration, and every s7 example in the documentation spells them that way. This binding read +// them unprefixed, so the documented connection string set nothing here and said nothing about it. +// This is the divergence this package was written to find; it is fixed, and pinned here. +func TestTheUnprefixedS7RackAndSlotAreNotAccepted(t *testing.T) { + logged := parseReporting(t, s7Parse, map[string][]string{ + "remote-rack": {"0"}, "remote-slot": {"1"}, + }) + assert.Contains(t, logged, "remote-rack") + assert.Contains(t, logged, "remote-slot") +} + +// The other half of that: a PLC4J parameter this binding does not implement is reported, so an +// operator finds out the setting does nothing here rather than believing it applied. +func TestAParameterThisBindingDoesNotImplementIsReported(t *testing.T) { + logged := parseReporting(t, s7Parse, map[string][]string{"ha-heartbeat-interval-ms": {"4000"}}) + assert.Contains(t, logged, "ha-heartbeat-interval-ms") + assert.Contains(t, logged, "not known", + "PLC4Go's s7 has no S7H dual-path, and says so rather than accepting the setting") +} + +// A spelling only this binding ever had must not be silently accepted, or the same string sets a +// value here and is ignored in PLC4J. This is the divergence that prompted the test: modbus read +// "unit-identifier", which PLC4J never declared, while UMAS uses that name for a different thing. +func TestAGoOnlySpellingIsReported(t *testing.T) { + logged := parseReporting(t, modbusParse, map[string][]string{"unit-identifier": {"9"}}) + assert.Contains(t, logged, "unit-identifier") + assert.Contains(t, logged, "not known", "it must be reported rather than quietly applied") +} + +// The pre-migration names are unknown in both bindings, so neither accepts what the other +// rejects. PLC4J asserts the same list in DriverBaseUnknownParameterTest. +func TestPreMigrationNamesAreReported(t *testing.T) { + for _, old := range []string{"request-timeout", "read-timeout", "connect-timeout"} { + t.Run(old, func(t *testing.T) { + logged := parseReporting(t, modbusParse, map[string][]string{old: {"1234"}}) + assert.Contains(t, logged, old, "the old spelling must be named") + }) + } +} diff --git a/plc4go/tools/plc4xGenerator/main.go b/plc4go/tools/plc4xGenerator/main.go index 8721260e5e4..c5441295f2e 100644 --- a/plc4go/tools/plc4xGenerator/main.go +++ b/plc4go/tools/plc4xGenerator/main.go @@ -273,6 +273,19 @@ func (g *Generator) generate(typeName string) { g.Printf("\td.%s.Lock()\n", field.hasLocker) g.Printf("\tdefer d.%s.Unlock()\n", field.hasLocker) } + // A marking applies whatever the field's type is. Handling it only where a string is + // rendered made the tag silently do nothing on every other kind of field - so a marking + // could be applied, reviewed, and still render the value. + if field.isSecret { + g.Printf(indent(0, secretFieldSerialize), fieldNameUntitled) + if field.hasLocker != "" { + g.Printf("\treturn nil\n") + g.Printf("}(); err != nil {\n") + g.Printf("\treturn err\n") + g.Printf("}\n") + } + continue + } needsDereference := false if starFieldType, ok := fieldType.(*ast.StarExpr); ok { fieldType = starFieldType.X @@ -604,6 +617,7 @@ type Field struct { fieldType ast.Expr isDelegate bool isStringer bool + isSecret bool asPtr bool directSerialize bool hasLocker string @@ -662,6 +676,13 @@ func (f *File) genDecl(node ast.Node) bool { if field.Tag != nil && field.Tag.Value == "`directSerialize:\"true\"`" { // TODO: Check if we do that a bit smarter directSerialize = true } + // A field carrying a credential renders as , never as its value. This is + // the Go counterpart of plc4j's @Secret: the marking sits on the declaration, so it + // cannot drift the way a list of secret-looking names in another package would. + isSecret := false + if field.Tag != nil && field.Tag.Value == "`secret:\"true\"`" { + isSecret = true + } if len(field.Names) == 0 { if *verbose { fmt.Printf("\t adding delegate\n") @@ -672,6 +693,7 @@ func (f *File) genDecl(node ast.Node) bool { fieldType: ft, isDelegate: true, isStringer: isStringer, + isSecret: isSecret, asPtr: asPtr, hasLocker: hasLocker, }) @@ -683,6 +705,7 @@ func (f *File) genDecl(node ast.Node) bool { fieldType: set, isDelegate: true, isStringer: isStringer, + isSecret: isSecret, asPtr: asPtr, hasLocker: hasLocker, }) @@ -692,6 +715,7 @@ func (f *File) genDecl(node ast.Node) bool { fieldType: set.Sel, isDelegate: true, isStringer: isStringer, + isSecret: isSecret, asPtr: asPtr, hasLocker: hasLocker, }) @@ -704,6 +728,7 @@ func (f *File) genDecl(node ast.Node) bool { fieldType: ft.Sel, isDelegate: true, isStringer: isStringer, + isSecret: isSecret, asPtr: asPtr, hasLocker: hasLocker, }) @@ -719,6 +744,7 @@ func (f *File) genDecl(node ast.Node) bool { name: field.Names[0].Name, fieldType: field.Type, isStringer: isStringer, + isSecret: isSecret, asPtr: asPtr, directSerialize: directSerialize, hasLocker: hasLocker, @@ -849,6 +875,14 @@ var boolFieldSerialize = ` } ` +// secretFieldSerialize renders a marked field's placeholder rather than its value. The field is +// still named, so a reader can see that a credential is configured without learning what it is. +var secretFieldSerialize = ` + if err := writeBuffer.WriteString(%[1]s, uint32(len("")*8), "", utils.WithEncoding("UTF-8")); err != nil { + return err + } +` + var stringFieldSerialize = ` if err := writeBuffer.WriteString(%[2]s, uint32(len(%[1]s)*8), %[1]s, utils.WithEncoding("UTF-8")); err != nil { return err diff --git a/plc4j/drivers/ab-eth/src/main/java/org/apache/plc4x/java/abeth/configuration/AbEthConfiguration.java b/plc4j/drivers/ab-eth/src/main/java/org/apache/plc4x/java/abeth/configuration/AbEthConfiguration.java index 6e951249a82..15fdf46fc46 100644 --- a/plc4j/drivers/ab-eth/src/main/java/org/apache/plc4x/java/abeth/configuration/AbEthConfiguration.java +++ b/plc4j/drivers/ab-eth/src/main/java/org/apache/plc4x/java/abeth/configuration/AbEthConfiguration.java @@ -30,7 +30,7 @@ public class AbEthConfiguration implements Configuration { @IntDefaultValue(0) private int station; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @Description("Maximum time (in milliseconds) to wait for the gateway to acknowledge the connection request or for a read response.") @IntDefaultValue(10_000) private int requestTimeout; diff --git a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/configuration/AdsConfiguration.java b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/configuration/AdsConfiguration.java index 8842538ec56..046545ade1b 100644 --- a/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/configuration/AdsConfiguration.java +++ b/plc4j/drivers/ads/src/main/java/org/apache/plc4x/java/ads/configuration/AdsConfiguration.java @@ -59,7 +59,7 @@ public class AdsConfiguration implements Configuration { @Description("AMS port of the source.") protected int sourceAmsPort; - @ConfigurationParameter("timeout-request") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(4000) @Description("Default timeout for all types of requests.") protected int timeoutRequest; diff --git a/plc4j/drivers/can/src/main/java/org/apache/plc4x/java/can/generic/configuration/GenericCANConfiguration.java b/plc4j/drivers/can/src/main/java/org/apache/plc4x/java/can/generic/configuration/GenericCANConfiguration.java index c96dde04034..d62e4dc4735 100644 --- a/plc4j/drivers/can/src/main/java/org/apache/plc4x/java/can/generic/configuration/GenericCANConfiguration.java +++ b/plc4j/drivers/can/src/main/java/org/apache/plc4x/java/can/generic/configuration/GenericCANConfiguration.java @@ -29,7 +29,7 @@ public class GenericCANConfiguration implements Configuration { @Description("Node id of the target device.") private int nodeId; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(1000) @Description("Default timeout for all types of requests.") private int requestTimeout; diff --git a/plc4j/drivers/canopen/src/main/java/org/apache/plc4x/java/canopen/configuration/CANOpenConfiguration.java b/plc4j/drivers/canopen/src/main/java/org/apache/plc4x/java/canopen/configuration/CANOpenConfiguration.java index a8585ae785b..8adbc8900ce 100644 --- a/plc4j/drivers/canopen/src/main/java/org/apache/plc4x/java/canopen/configuration/CANOpenConfiguration.java +++ b/plc4j/drivers/canopen/src/main/java/org/apache/plc4x/java/canopen/configuration/CANOpenConfiguration.java @@ -35,7 +35,7 @@ public class CANOpenConfiguration implements Configuration { @Description("Forces PLC4X to send CANopen heartbeat (NMT) messages to the bus.") private boolean heartbeat; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(1000) @Description("Time after which dispatched BUS operation (ie. SDO request) will be marked as failed.") private int requestTimeout; diff --git a/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXDriver.java b/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXDriver.java index 6115ad46df9..37367ef80aa 100644 --- a/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXDriver.java +++ b/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXDriver.java @@ -29,6 +29,7 @@ import org.apache.plc4x.java.ctrlx.readwrite.connection.CtrlXConnection; import org.apache.plc4x.java.ctrlx.readwrite.discovery.CtrlXPlcDiscoverer; import org.apache.plc4x.java.spi.config.ConfigurationFactory; +import org.apache.plc4x.java.spi.drivers.UnknownParameterReporter; import org.apache.plc4x.java.spi.drivers.DriverBase; import org.apache.plc4x.java.spi.drivers.messages.DefaultPlcDiscoveryRequest; @@ -79,6 +80,12 @@ public PlcConnection getConnection(String connectionString, PlcAuthentication au throw new PlcConnectionException("Unsupported configuration"); } + // This driver implements PlcDriver directly rather than extending DriverBase, so nothing + // reports a parameter the configuration does not declare unless it does so itself. Without + // this a typo was accepted in silence and the connection ran on defaults. + UnknownParameterReporter.report(getProtocolCode(), paramString, transportCode, + CtrlXConfiguration.class, null); + // CtrlX only supports "https" as transport. if(!"https".equals(transportCode)) { throw new PlcConnectionException("Only 'https' transport is supported by this driver"); diff --git a/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/configuration/CtrlXConfiguration.java b/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/configuration/CtrlXConfiguration.java index ebff80e5bd2..6c0f7613965 100644 --- a/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/configuration/CtrlXConfiguration.java +++ b/plc4j/drivers/ctrlx/src/main/java/org/apache/plc4x/java/ctrlx/readwrite/configuration/CtrlXConfiguration.java @@ -21,6 +21,7 @@ import org.apache.plc4x.java.spi.config.Configuration; import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; import org.apache.plc4x.java.spi.config.annotations.defaults.IntDefaultValue; import org.apache.plc4x.java.spi.config.annotations.Description; import org.apache.plc4x.java.spi.config.annotations.defaults.BooleanDefaultValue; @@ -35,17 +36,18 @@ public class CtrlXConfiguration implements Configuration { *

Named the same as the equivalent in the OPC UA driver and the TLS transport, so that * pinning a device's own certificate reads the same wherever it is done.

*/ - @ConfigurationParameter("trust-store-file") + @ConfigurationParameter("tls.trust-store") @Description("Key store of certificates to trust, instead of the JVM's public authorities") public String trustStoreFile; - @ConfigurationParameter("trust-store-password") - @Description("Password of the trust store named by trust-store-file") + @Secret + @ConfigurationParameter("tls.trust-store-password") + @Description("Password of the trust store named by tls.trust-store") public String trustStorePassword; - @ConfigurationParameter("trust-store-type") + @ConfigurationParameter("tls.trust-store-type") @StringDefaultValue("PKCS12") - @Description("Type of the trust store named by trust-store-file") + @Description("Type of the trust store named by tls.trust-store") public String trustStoreType; /** diff --git a/plc4j/drivers/ctrlx/src/test/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXTrustConfigurationTest.java b/plc4j/drivers/ctrlx/src/test/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXTrustConfigurationTest.java index 52a1e769a68..319fc402ec0 100644 --- a/plc4j/drivers/ctrlx/src/test/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXTrustConfigurationTest.java +++ b/plc4j/drivers/ctrlx/src/test/java/org/apache/plc4x/java/ctrlx/readwrite/CtrlXTrustConfigurationTest.java @@ -66,7 +66,7 @@ void aDevicesOwnCertificateCanBeNamed() { @Test void aTrustStoreCanBeNamedTheSameWayAsElsewhere() { CtrlXConfiguration configuration = configFrom( - "trust-store-file=/etc/plc4x/trust.p12&trust-store-password=secret&trust-store-type=JKS"); + "tls.trust-store=/etc/plc4x/trust.p12&tls.trust-store-password=secret&tls.trust-store-type=JKS"); assertEquals("/etc/plc4x/trust.p12", configuration.getTrustStoreFile()); assertEquals("secret", configuration.getTrustStorePassword()); assertEquals("JKS", configuration.getTrustStoreType()); @@ -74,7 +74,7 @@ void aTrustStoreCanBeNamedTheSameWayAsElsewhere() { @Test void theTrustStoreTypeDefaultsToTheSameAsTheOtherDrivers() { - CtrlXConfiguration configuration = configFrom("trust-store-file=/etc/plc4x/trust.p12"); + CtrlXConfiguration configuration = configFrom("tls.trust-store=/etc/plc4x/trust.p12"); assertEquals("PKCS12", configuration.getTrustStoreType()); } } diff --git a/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/configuration/EIPConfiguration.java b/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/configuration/EIPConfiguration.java index 7a04bd961f4..c86e7815a51 100644 --- a/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/configuration/EIPConfiguration.java +++ b/plc4j/drivers/eip/src/main/java/org/apache/plc4x/java/eip/base/configuration/EIPConfiguration.java @@ -56,7 +56,7 @@ public class EIPConfiguration implements Configuration { @Since("0.13.0") private boolean forceUnconnectedOperation = false; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(10_000) @Description("Default timeout for all types of requests.") private int requestTimeout; diff --git a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/configuration/FirmataConfiguration.java b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/configuration/FirmataConfiguration.java index ab77d0bca4a..2885437df09 100644 --- a/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/configuration/FirmataConfiguration.java +++ b/plc4j/drivers/firmata/src/main/java/org/apache/plc4x/java/firmata/configuration/FirmataConfiguration.java @@ -25,7 +25,7 @@ public class FirmataConfiguration implements Configuration { - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(10_000) @Description("Maximum time (in milliseconds) to wait for the initial firmware-report reply during connection setup.") private int requestTimeout; diff --git a/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/FirmataVirtualAvrIT.java b/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/FirmataVirtualAvrIT.java index 414c5acf49e..148ff5de941 100644 --- a/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/FirmataVirtualAvrIT.java +++ b/plc4j/drivers/firmata/src/test/java/org/apache/plc4x/java/firmata/FirmataVirtualAvrIT.java @@ -154,7 +154,7 @@ void teardown() { private PlcConnection openDriverAndAttachWebSocket() throws Exception { String url = "firmata:tcp://" + virtualAvr.getHost() + ":" + virtualAvr.getMappedPort(CONTAINER_TCP_SERIAL_PORT) - + "?request-timeout=30000"; + + "?request-timeout-ms=30000"; PlcConnection connection = openWithRetry(url); // Build the WebSocket URI manually — the library's static factory diff --git a/plc4j/drivers/iec-60870/src/main/java/org/apache/plc4x/java/iec608705104/configuration/Iec608705014Configuration.java b/plc4j/drivers/iec-60870/src/main/java/org/apache/plc4x/java/iec608705104/configuration/Iec608705014Configuration.java index 87d20a3edac..6dcd1f5cc0b 100644 --- a/plc4j/drivers/iec-60870/src/main/java/org/apache/plc4x/java/iec608705104/configuration/Iec608705014Configuration.java +++ b/plc4j/drivers/iec-60870/src/main/java/org/apache/plc4x/java/iec608705104/configuration/Iec608705014Configuration.java @@ -26,7 +26,7 @@ public class Iec608705014Configuration implements Configuration { - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(4000) @Description("Maximum time (in milliseconds) to wait for the test-frame and start-data-transfer handshake replies during connection setup.") protected int requestTimeout; diff --git a/plc4j/drivers/knxnetip/src/main/java/org/apache/plc4x/java/knxnetip/configuration/KnxNetIpConfiguration.java b/plc4j/drivers/knxnetip/src/main/java/org/apache/plc4x/java/knxnetip/configuration/KnxNetIpConfiguration.java index 5cbb89d5dad..48dbfd9c9ea 100644 --- a/plc4j/drivers/knxnetip/src/main/java/org/apache/plc4x/java/knxnetip/configuration/KnxNetIpConfiguration.java +++ b/plc4j/drivers/knxnetip/src/main/java/org/apache/plc4x/java/knxnetip/configuration/KnxNetIpConfiguration.java @@ -21,6 +21,7 @@ import org.apache.plc4x.java.knxnetip.readwrite.KnxLayer; import org.apache.plc4x.java.spi.config.Configuration; import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; import org.apache.plc4x.java.spi.config.annotations.Description; import org.apache.plc4x.java.spi.config.annotations.defaults.IntDefaultValue; import org.apache.plc4x.java.spi.config.annotations.defaults.StringDefaultValue; @@ -34,31 +35,34 @@ public class KnxNetIpConfiguration implements Configuration { @Description("Path to the `knxproj` file. The default KNXnet/IP protocol doesn't provide all the information needed to be able to fully decode the messages.") public File knxprojFile; + @Secret @ConfigurationParameter("knxproj-password") @Description("Optional password needed to read the knxproj file.") public String knxprojPassword; @ConfigurationParameter("group-address-num-levels") @IntDefaultValue(3) - @Description("KNX Addresses can be encoded in multiple ways. Which encoding is used, is too not provided by the protocol itself so it has to be provided externally:\n" + - "\n" + - "- 3 Levels: {main-group (5 bit)}/{middle-group (3 bit)}/{sub-group (8 bit)}\n" + - "- 2 Levels: {main-group (5 bit)}/{sub-group (11 bit)}\n" + - "- 1 Level: {sub-group (16 bit)}\n" + - "\n" + - "The default is 3 levels. If the `knxproj-file-path` this information is provided by the file.") + @Description(""" + KNX Addresses can be encoded in multiple ways. Which encoding is used, is too not provided by the protocol itself so it has to be provided externally: + + - 3 Levels: {main-group (5 bit)}/{middle-group (3 bit)}/{sub-group (8 bit)} + - 2 Levels: {main-group (5 bit)}/{sub-group (11 bit)} + - 1 Level: {sub-group (16 bit)} + + The default is 3 levels. If the `knxproj-file-path` this information is provided by the file.""") public int groupAddressNumLevels = 3; @ConfigurationParameter("connection-type") @StringDefaultValue("LINK_LAYER") - @Description("Type of connection used to communicate. Possible values are:\n" + - "\n" + - "- 'LINK_LAYER' (default): The client becomes a participant of the KNX bus and gets it's own individual KNX address.\n" + - "- 'RAW': The client gets unmanaged access to the bus (be careful with this)\n" + - "- 'BUSMONITOR': The client operates as a busmonitor where he can't actively participate on the bus. Only one 'BUSMONITOR' connection is allowed at the same time on a KNXnet/IP gateway.") + @Description(""" + Type of connection used to communicate. Possible values are: + + - 'LINK_LAYER' (default): The client becomes a participant of the KNX bus and gets it's own individual KNX address. + - 'RAW': The client gets unmanaged access to the bus (be careful with this) + - 'BUSMONITOR': The client operates as a busmonitor where he can't actively participate on the bus. Only one 'BUSMONITOR' connection is allowed at the same time on a KNXnet/IP gateway.""") public String connectionType = "LINK_LAYER"; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(10_000) @Description("Maximum time (in milliseconds) to wait for a reply during the KNXnet/IP search, connect and tunnelling exchanges.") public int requestTimeout = 10_000; diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java index 4cc21b729c0..982086972f9 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/ascii/config/ModbusAsciiConfiguration.java @@ -28,7 +28,7 @@ public class ModbusAsciiConfiguration implements Configuration { - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(5_000) @Description("Default timeout for all types of requests. The timeout covers the full time from submission including queueing; queued requests whose remaining budget falls below a small dispatch margin (at most a quarter of the timeout, capped at 50 ms) fail fast instead of being sent.") private int requestTimeout; diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java index b5165dbcabc..18b45a7f18f 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/rtu/config/ModbusRtuConfiguration.java @@ -28,7 +28,7 @@ public class ModbusRtuConfiguration implements Configuration { - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(5_000) @Description("Default timeout for all types of requests. The timeout covers the full time from submission including queueing; queued requests whose remaining budget falls below a small dispatch margin (at most a quarter of the timeout, capped at 50 ms) fail fast instead of being sent.") private int requestTimeout; diff --git a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/tcp/config/ModbusTcpConfiguration.java b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/tcp/config/ModbusTcpConfiguration.java index 65a57e21662..329f27bbf67 100644 --- a/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/tcp/config/ModbusTcpConfiguration.java +++ b/plc4j/drivers/modbus/src/main/java/org/apache/plc4x/java/modbus/tcp/config/ModbusTcpConfiguration.java @@ -28,7 +28,7 @@ public class ModbusTcpConfiguration implements Configuration { - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(5_000) @Description("Default timeout for all types of requests.") private int requestTimeout; diff --git a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusDockerIT.java b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusDockerIT.java index 2f4ee327c39..f593b19d73f 100644 --- a/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusDockerIT.java +++ b/plc4j/drivers/modbus/src/test/java/org/apache/plc4x/java/modbus/ModbusDockerIT.java @@ -648,7 +648,7 @@ void testTcpUdpReadInputRegisters() throws Exception { @DisplayName("TCP/TLS: Read holding registers") void testTcpTlsReadHoldingRegisters() throws Exception { try (var conn = new DefaultPlcDriverManager().getConnection( - String.format("modbus-tcp:tls://%s:%d?tls.verify-ssl=false", host, tlsPort))) { + String.format("modbus-tcp:tls://%s:%d?tls.verify=false", host, tlsPort))) { var resp = conn.readRequestBuilder() .addTagAddress("bool", "holding-register:1:BOOL") .addTagAddress("int", "holding-register:12:INT") @@ -664,7 +664,7 @@ void testTcpTlsReadHoldingRegisters() throws Exception { @DisplayName("TCP/TLS: Write and read-back") void testTcpTlsWriteReadBack() throws Exception { try (var conn = new DefaultPlcDriverManager().getConnection( - String.format("modbus-tcp:tls://%s:%d?tls.verify-ssl=false", host, tlsPort))) { + String.format("modbus-tcp:tls://%s:%d?tls.verify=false", host, tlsPort))) { conn.writeRequestBuilder().addTagAddress("value", "holding-register:170:INT", (short) -5555).build().execute().get(); assertEquals(-5555, (int) conn.readRequestBuilder().addTagAddress("value", "holding-register:170:INT").build().execute().get().getShort("value")); } @@ -674,7 +674,7 @@ void testTcpTlsWriteReadBack() throws Exception { @DisplayName("TCP/TLS: Read coils") void testTcpTlsReadCoils() throws Exception { try (var conn = new DefaultPlcDriverManager().getConnection( - String.format("modbus-tcp:tls://%s:%d?tls.verify-ssl=false", host, tlsPort))) { + String.format("modbus-tcp:tls://%s:%d?tls.verify=false", host, tlsPort))) { assertTrue(conn.readRequestBuilder().addTagAddress("value", "coil:1").build().execute().get().getBoolean("value")); } } @@ -683,7 +683,7 @@ void testTcpTlsReadCoils() throws Exception { @DisplayName("TCP/TLS: Read discrete inputs") void testTcpTlsReadDiscreteInputs() throws Exception { try (var conn = new DefaultPlcDriverManager().getConnection( - String.format("modbus-tcp:tls://%s:%d?tls.verify-ssl=false", host, tlsPort))) { + String.format("modbus-tcp:tls://%s:%d?tls.verify=false", host, tlsPort))) { assertTrue(conn.readRequestBuilder().addTagAddress("value", "discrete-input:2").build().execute().get().getBoolean("value")); } } @@ -692,7 +692,7 @@ void testTcpTlsReadDiscreteInputs() throws Exception { @DisplayName("TCP/TLS: Read input registers") void testTcpTlsReadInputRegisters() throws Exception { try (var conn = new DefaultPlcDriverManager().getConnection( - String.format("modbus-tcp:tls://%s:%d?tls.verify-ssl=false", host, tlsPort))) { + String.format("modbus-tcp:tls://%s:%d?tls.verify=false", host, tlsPort))) { assertEquals(42424, (int) conn.readRequestBuilder().addTagAddress("value", "input-register:3:UINT").build().execute().get().getInteger("value")); } } diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java index 953d82ef6a4..0417e5d0e09 100644 --- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java +++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/OpcuaConnection.java @@ -213,7 +213,7 @@ protected void onConnect() throws PlcConnectionException { "Cannot establish a %s channel: the server's certificate is not known in " + "advance, so the connection would fall back to an unprotected channel. " + "Name the certificate with 'server-certificate-file' or a trust store " - + "with 'trust-store-file', or set 'discovery=false' if the endpoint " + + "with 'tls.trust-store', or set 'discovery=false' if the endpoint " + "needs no discovery, or ask for 'security-policy=NONE' to accept an " + "unprotected channel.", configuration.getSecurityPolicy())); diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java index f616ce612aa..0907af23bc5 100644 --- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java +++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/config/OpcuaConfiguration.java @@ -28,6 +28,7 @@ import org.apache.plc4x.java.opcua.security.SecurityPolicy; import org.apache.plc4x.java.spi.config.annotations.ComplexConfigurationParameter; import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; import org.apache.plc4x.java.spi.config.annotations.Description; import org.apache.plc4x.java.spi.config.annotations.defaults.BooleanDefaultValue; import org.apache.plc4x.java.spi.config.annotations.defaults.LongDefaultValue; @@ -47,139 +48,159 @@ public class OpcuaConfiguration implements Configuration { @ConfigurationParameter("discovery") @BooleanDefaultValue(true) - @Description("Controls the feature of the discovery endpoint of an OPC UA server which every server\n" + - "will propagate over an '
/discovery' endpoint. The most common issue here is that most servers are not correctly\n" + - "configured and propagate the wrong external IP or URL address. If that is the case you can disable the discovery by\n" + - "configuring it with a `false` value.\n" + - "\n" + - "The discovery phase is always conducted using `NONE` security policy.") + @Description(""" + Controls the feature of the discovery endpoint of an OPC UA server which every server + will propagate over an '
/discovery' endpoint. The most common issue here is that most servers are not correctly + configured and propagate the wrong external IP or URL address. If that is the case you can disable the discovery by + configuring it with a `false` value. + + The discovery phase is always conducted using `NONE` security policy.""") private boolean discovery; @ConfigurationParameter("username") @Description("A username to authenticate to the OPCUA server with.") private String username; + @Secret @ConfigurationParameter("password") @Description("A password to authenticate to the OPCUA server with.") private String password; @ConfigurationParameter("security-policy") @StringDefaultValue("Basic256Sha256") - @Description("The security policy applied to communication channel between driver and OPC UA server.\n" + - "Possible options are `NONE`, `Basic128Rsa15`, `Basic256`, `Basic256Sha256`, `Aes128_Sha256_RsaOaep`, `Aes256_Sha256_RsaPss`.\n" + - "`NONE` means the channel is neither signed nor encrypted, so anything on the path can read and\n" + - "change what is exchanged; it also leaves the server unauthenticated. A policy that signs and\n" + - "encrypts needs a trust anchor for the server's certificate - see `trust-store-file` and\n" + - "`server-certificate-file`.") + @Description(""" + The security policy applied to communication channel between driver and OPC UA server. + Possible options are `NONE`, `Basic128Rsa15`, `Basic256`, `Basic256Sha256`, `Aes128_Sha256_RsaOaep`, `Aes256_Sha256_RsaPss`. + `NONE` means the channel is neither signed nor encrypted, so anything on the path can read and + change what is exchanged; it also leaves the server unauthenticated. A policy that signs and + encrypts needs a trust anchor for the server's certificate - see `tls.trust-store` and + `server-certificate-file`.""") private SecurityPolicy securityPolicy; @ConfigurationParameter("message-security") @StringDefaultValue("SIGN_ENCRYPT") - @Description("The security policy applied to messages exchanged after handshake phase.\n" + - "Possible options are `NONE`, `SIGN`, `SIGN_ENCRYPT`.\n" + - "This option is effective only when `securityPolicy` turns encryption (anything beyond `NONE`).") + @Description(""" + The security policy applied to messages exchanged after handshake phase. + Possible options are `NONE`, `SIGN`, `SIGN_ENCRYPT`. + This option is effective only when `securityPolicy` turns encryption (anything beyond `NONE`).""") private MessageSecurity messageSecurity; - @ConfigurationParameter("key-store-file") + @ConfigurationParameter("tls.keystore") @Description("The Keystore file used to lookup client certificate and its private key.") private String keyStoreFile; - @ConfigurationParameter("key-store-type") + @ConfigurationParameter("tls.keystore-type") @StringDefaultValue("pkcs12") @Description("Keystore type used to access keystore and private key, defaults to PKCS (for Java 11+).\n" + "Possible values are between others `jks`, `pkcs11`, `dks`, `jceks`.") private String keyStoreType; - @ConfigurationParameter("key-store-password") + @Secret + @ConfigurationParameter("tls.keystore-password") @Description("Java keystore password used to access keystore and private key.") private String keyStorePassword; @ConfigurationParameter("generated-key-size") @IntDefaultValue(2048) - @Description("Size in bits of the RSA key of the certificate the driver generates when no `key-store-file` is configured. It is ignored when a key store is supplied, as the key then comes from that store. Some servers require a minimum size; 4096 is a common requirement.") + @Description("Size in bits of the RSA key of the certificate the driver generates when no `tls.keystore` is configured. It is ignored when a key store is supplied, as the key then comes from that store. Some servers require a minimum size; 4096 is a common requirement.") private int generatedKeySize; @ConfigurationParameter("server-certificate-file") @Description("Filesystem location where server certificate is located, supported formats are `DER` and `PEM`.") private String serverCertificateFile; - @ConfigurationParameter("trust-store-file") + @ConfigurationParameter("tls.trust-store") @Description("The trust store file used to verify server certificates and its chain.") private String trustStoreFile; - @ConfigurationParameter("trust-store-type") + @ConfigurationParameter("tls.trust-store-type") @StringDefaultValue("pkcs12") @Description("Keystore type used to access keystore and private key, defaults to PKCS (for Java 11+).\n" + "Possible values are between others `jks`, `pkcs11`, `dks`, `jceks`.") private String trustStoreType; - @ConfigurationParameter("trust-store-password") + @Secret + @ConfigurationParameter("tls.trust-store-password") @Description("Password used to open trust store.") private String trustStorePassword; @ConfigurationParameter("allow-insecure-credentials") @BooleanDefaultValue(false) - @Description("Allows a username and password to be sent over a channel that neither signs nor encrypts.\n" + - "Without this, a connection configured with credentials over an unprotected channel fails rather\n" + - "than putting the password on the wire where anything on the path can read it. Setting it warns.") + @Description(""" + Allows a username and password to be sent over a channel that neither signs nor encrypts. + Without this, a connection configured with credentials over an unprotected channel fails rather + than putting the password on the wire where anything on the path can read it. Setting it warns.""") private boolean allowInsecureCredentials; @ConfigurationParameter("browse-max-references-per-node") @IntDefaultValue(65536) - @Description("Largest number of references the driver will collect for a single node while browsing.\n" + - "A Browse is answered in batches, each batch handing back a continuation point for the next, and\n" + - "the driver follows them until the server stops. A server that never stops would otherwise grow\n" + - "the collected list without limit. The same number is asked of the server as its per-node maximum,\n" + - "so it can stop before the driver has to. Set to 0 for no limit.") + @Description(""" + Largest number of references the driver will collect for a single node while browsing. + A Browse is answered in batches, each batch handing back a continuation point for the next, and + the driver follows them until the server stops. A server that never stops would otherwise grow + the collected list without limit. The same number is asked of the server as its per-node maximum, + so it can stop before the driver has to. Set to 0 for no limit.""") private int browseMaxReferencesPerNode; @ConfigurationParameter("browse-max-total-nodes") @IntDefaultValue(1000000) - @Description("Largest number of nodes a single browse will expand. A browse walks whatever tree the\n" + - "server describes, and the driver has no way to know how large that is before walking it, so this\n" + - "bounds a tree that turns out to be unreasonable - or endless, if the server keeps naming nodes it\n" + - "has not named before. Set to 0 for no limit.") + @Description(""" + Largest number of nodes a single browse will expand. A browse walks whatever tree the + server describes, and the driver has no way to know how large that is before walking it, so this + bounds a tree that turns out to be unreasonable - or endless, if the server keeps naming nodes it + has not named before. Set to 0 for no limit.""") private int browseMaxTotalNodes; @ConfigurationParameter("browse-max-depth") @IntDefaultValue(64) - @Description("How deep a browse will recurse into the node tree. Already-visited nodes are never\n" + - "expanded twice, so a reference cycle terminates on its own, but a server naming a fresh node at\n" + - "every level describes a tree with no bottom. Set to 0 for no limit.") + @Description(""" + How deep a browse will recurse into the node tree. Already-visited nodes are never + expanded twice, so a reference cycle terminates on its own, but a server naming a fresh node at + every level describes a tree with no bottom. Set to 0 for no limit.""") private int browseMaxDepth; - @ConfigurationParameter("insecure-certificate-verification") - @BooleanDefaultValue(false) - @Description("Disables verification of the OPC UA server certificate, trusting any certificate the server presents.\n" + - "This is UNSAFE: it leaves the connection open to man-in-the-middle attacks and defeats the integrity/authenticity\n" + - "guarantees of a signed secure channel. Only enable it for local testing. In production, establish trust with\n" + - "`trust-store-file` (chain validation) or `server-certificate-file` (certificate pinning) instead.") - private boolean insecureCertificateVerification; + // Spelled with the "tls." namespace although this driver has no tls transport: OPC UA + // negotiates its own secure channel, so the setting is declared here rather than inherited + // from a transport, and the namespace is written into the name by hand. See the tls + // transport's "verify", which is the same concept one layer down. + // + // This replaced "insecure-certificate-verification", whose sense was the opposite. The + // default is now to verify, so an address that misses the migration fails against a server + // whose certificate does not validate, rather than connecting without checking it. + @ConfigurationParameter("tls.verify") + @BooleanDefaultValue(true) + @Description(""" + Verifies the OPC UA server's certificate. Set to false to trust any certificate the server presents. + Turning it off is UNSAFE: it leaves the connection open to man-in-the-middle attacks and defeats the + integrity/authenticity guarantees of a signed secure channel. Only do so for local testing. In production, + establish trust with `tls.trust-store` (chain validation) or `server-certificate-file` (certificate + pinning) instead.""") + private boolean verifyServerCertificate; // the discovered certificate when discovery is enabled private X509Certificate serverCertificate; - @ConfigurationParameter("channel-lifetime") + @ConfigurationParameter("channel-lifetime-ms") @LongDefaultValue(3600000) @Description("Time for which negotiated secure channel, its keys and session remains open. Value in milliseconds, by default 60 minutes.") private long channelLifetime; - @ConfigurationParameter("min-channel-lifetime") + @ConfigurationParameter("min-channel-lifetime-ms") @LongDefaultValue(5000) - @Description("Shortest secure-channel lifetime this client will work with, in milliseconds. A server may revise the requested channel-lifetime downwards, and the renewal schedule is derived from whatever it returns - so a very short lifetime means very frequent renewals, on an executor shared by every OPC UA connection in this JVM. A server-supplied lifetime below this value is raised to it and a warning is logged. If a server genuinely needs faster renewal, lower this value to accept it; the default is far below any lifetime a conforming server negotiates.") + @Description("Shortest secure-channel lifetime this client will work with, in milliseconds. A server may revise the requested channel-lifetime-ms downwards, and the renewal schedule is derived from whatever it returns - so a very short lifetime means very frequent renewals, on an executor shared by every OPC UA connection in this JVM. A server-supplied lifetime below this value is raised to it and a warning is logged. If a server genuinely needs faster renewal, lower this value to accept it; the default is far below any lifetime a conforming server negotiates.") private long minChannelLifetime; - @ConfigurationParameter("session-timeout") + @ConfigurationParameter("session-timeout-ms") @LongDefaultValue(120000) @Description("Expiry time for opened secure session, value in milliseconds. Defaults to 2 minutes.") private long sessionTimeout; - @ConfigurationParameter("negotiation-timeout") + @ConfigurationParameter("handshake-timeout-ms") @LongDefaultValue(60000) @Description("Timeout for all negotiation steps prior acceptance of application level operations - this timeout applies to open secure channel, create session and close calls. Defaults to 60 seconds.") private long negotiationTimeout; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @LongDefaultValue(30000) @Description("Timeout for read/write/subscribe calls. Value in milliseconds.") private long requestTimeout; @@ -198,9 +219,10 @@ public class OpcuaConfiguration implements Configuration { @ConfigurationParameter("subscription-queue-size") @LongDefaultValue(1) - @Description("Server-side queue depth per monitored item for subscriptions. 1 (default) keeps only\n" + - "the latest value between publishes; higher values retain intermediate changes for fast\n" + - "change-of-state tags, whose sampling rate can exceed the publishing (cycle) interval.") + @Description(""" + Server-side queue depth per monitored item for subscriptions. 1 (default) keeps only + the latest value between publishes; higher values retain intermediate changes for fast + change-of-state tags, whose sampling rate can exceed the publishing (cycle) interval.""") private long subscriptionQueueSize; public String getProtocolCode() { @@ -291,8 +313,8 @@ public void setBrowseMaxDepth(int browseMaxDepth) { this.browseMaxDepth = browseMaxDepth; } - public boolean isInsecureCertificateVerification() { - return insecureCertificateVerification; + public boolean isVerifyServerCertificate() { + return verifyServerCertificate; } /** diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java index 2e34f4547b2..88446f9ba10 100644 --- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java +++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/OpcuaDriverContext.java @@ -120,8 +120,8 @@ public void openKeyStore(OpcuaConfiguration configuration) throws IOException, G /** * Selects the server-certificate trust strategy, in order of precedence: *
    - *
  1. {@code insecure-certificate-verification=true} → trust everything (unsafe, opt-in only);
  2. - *
  3. a {@code trust-store-file} → validate the certificate chain against the trust store;
  4. + *
  5. {@code tls.verify=false} → trust everything (unsafe, opt-in only);
  6. + *
  7. a {@code tls.trust-store} → validate the certificate chain against the trust store;
  8. *
  9. a {@code server-certificate-file} → pin trust to that exact certificate;
  10. *
  11. otherwise → fail closed and reject, since no trust anchor is available.
  12. *
@@ -131,10 +131,10 @@ public void openKeyStore(OpcuaConfiguration configuration) throws IOException, G */ private CertificateVerifier buildCertificateVerifier(OpcuaConfiguration configuration) throws IOException, GeneralSecurityException { - if (configuration.isInsecureCertificateVerification()) { - LOGGER.warn("OPC UA server certificate verification is DISABLED " - + "('insecure-certificate-verification=true'). The connection is vulnerable to " - + "man-in-the-middle attacks; do not use this in production."); + if (!configuration.isVerifyServerCertificate()) { + LOGGER.warn("OPC UA server certificate verification is DISABLED ('tls.verify=false'). " + + "The connection is vulnerable to man-in-the-middle attacks; do not use this in " + + "production."); return new PermissiveCertificateVerifier(); } if (configuration.getTrustStoreFile() != null) { @@ -145,8 +145,8 @@ private CertificateVerifier buildCertificateVerifier(OpcuaConfiguration configur LOGGER.info("Pinning OPC UA server certificate trust to {}", configuration.getServerCertificateFile()); return new PinnedCertificateVerifier(configuration.getServerCertificate()); } - LOGGER.warn("No OPC UA trust anchor configured ('trust-store-file' or 'server-certificate-file'); " - + "server certificates will be rejected. Set 'insecure-certificate-verification=true' to bypass " + LOGGER.warn("No OPC UA trust anchor configured ('tls.trust-store' or 'server-certificate-file'); " + + "server certificates will be rejected. Set 'tls.verify=false' to bypass " + "verification for local testing only."); return new RejectingCertificateVerifier(); } diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java index b792870a553..076f82de8dd 100644 --- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java +++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/context/SecureChannel.java @@ -502,10 +502,10 @@ private long adoptChannelLifetime(long revisedLifetime) { if (revisedLifetime > 0 && revisedLifetime < effective && !shortLifetimeWarned) { shortLifetimeWarned = true; LOGGER.warn("Server asked for a secure channel lifetime of {} ms; using {} ms instead, " - + "because min-channel-lifetime is {} ms. Renewals share one executor with every " + + "because min-channel-lifetime-ms is {} ms. Renewals share one executor with every " + "OPC UA connection in this JVM, which is what that minimum protects. The server " + "may treat the channel as expired before the first renewal - if this server " - + "genuinely needs renewal that often, lower min-channel-lifetime to {} or less.", + + "genuinely needs renewal that often, lower min-channel-lifetime-ms to {} or less.", revisedLifetime, effective, minimum, revisedLifetime); } return effective; @@ -525,12 +525,12 @@ private long adoptChannelLifetime(long revisedLifetime) { * before our first renewal is due, so the connection may fail at that point. That is the * trade being made - the renewals run on an executor shared by every OPC UA connection * in the JVM, so one peer does not get to set the pace for all of them. An operator who - * needs such a server lowers {@code min-channel-lifetime} and accepts the cost + * needs such a server lowers {@code min-channel-lifetime-ms} and accepts the cost * knowingly. * * *

The minimum is bounded by the requested lifetime, so a deliberately short - * {@code channel-lifetime} is still honoured: this only ever declines to go below + * {@code channel-lifetime-ms} is still honoured: this only ever declines to go below * what the operator asked for, never above it.

*/ static long effectiveChannelLifetime(long revisedLifetime, long requestedLifetime, long minimumLifetime) { diff --git a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java index 8cc9ff1c560..406192bde1d 100644 --- a/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java +++ b/plc4j/drivers/opcua/src/main/java/org/apache/plc4x/java/opcua/security/RejectingCertificateVerifier.java @@ -25,10 +25,10 @@ /** * Fail-closed certificate verifier used as the secure default: it rejects every * server certificate because no trust anchor has been configured. To establish - * trust, configure either a {@code trust-store-file} (chain validation) or a + * trust, configure either a {@code tls.trust-store} (chain validation) or a * {@code server-certificate-file} (certificate pinning). As a last resort, * certificate verification can be disabled entirely with - * {@code insecure-certificate-verification=true}, which is unsafe and leaves the + * {@code tls.verify=false}, which is unsafe and leaves the * connection open to man-in-the-middle attacks. */ public class RejectingCertificateVerifier implements CertificateVerifier { @@ -36,8 +36,8 @@ public class RejectingCertificateVerifier implements CertificateVerifier { @Override public void checkCertificateTrusted(X509Certificate certificate) throws CertificateException { throw new CertificateException("No trust anchor configured for OPC UA server certificate verification. " - + "Configure 'trust-store-file' or 'server-certificate-file' to establish trust, or set " - + "'insecure-certificate-verification=true' to disable verification (unsafe)."); + + "Configure 'tls.trust-store' or 'server-certificate-file' to establish trust, or set " + + "'tls.verify=false' to disable verification (unsafe)."); } } diff --git a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java index b71cbe0f9a7..f191b454393 100644 --- a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java +++ b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/OpcuaPlcDriverTest.java @@ -248,9 +248,9 @@ public void startUp() throws Exception { untrustedTcpConnectionAddress = String.format(opcPattern + miloLocalAddress, milo.getHost(), milo.getMappedPort(12686)) + "?endpoint-port=12686" - + "&key-store-file=" + CLIENT_KEY_STORE.getAbsoluteFile().toString().replace("\\", "/") - + "&key-store-password=changeit" - + "&key-store-type=pkcs12"; + + "&tls.keystore=" + CLIENT_KEY_STORE.getAbsoluteFile().toString().replace("\\", "/") + + "&tls.keystore-password=changeit" + + "&tls.keystore-type=pkcs12"; tcpConnectionAddress = untrustedTcpConnectionAddress + "&server-certificate-file=" + SERVER_CERTIFICATE.toString().replace("\\", "/"); connectionStringValidSet = List.of(tcpConnectionAddress); @@ -947,9 +947,9 @@ void staticConfig() throws Exception { String options = params( entry("discovery", "false"), entry("server-certificate-file", SERVER_CERTIFICATE.toString().replace("\\", "/")), - entry("key-store-file", CLIENT_KEY_STORE.toString().replace("\\", "/")), // handle windows paths - entry("key-store-password", "changeit"), - entry("key-store-type", "pkcs12"), + entry("tls.keystore", CLIENT_KEY_STORE.toString().replace("\\", "/")), // handle windows paths + entry("tls.keystore-password", "changeit"), + entry("tls.keystore-type", "pkcs12"), entry("security-policy", SecurityPolicy.Basic256Sha256.name()), entry("message-security", MessageSecurity.SIGN.name()) ); @@ -979,9 +979,9 @@ void staticConfig() throws Exception { void securedConnectionRelyingOnDiscoveryIsRejected() { String options = params( entry("discovery", "true"), - entry("key-store-file", CLIENT_KEY_STORE.toString().replace("\\", "/")), - entry("key-store-password", "changeit"), - entry("key-store-type", "pkcs12"), + entry("tls.keystore", CLIENT_KEY_STORE.toString().replace("\\", "/")), + entry("tls.keystore-password", "changeit"), + entry("tls.keystore-type", "pkcs12"), entry("security-policy", SecurityPolicy.Basic256Sha256.name()), entry("message-security", MessageSecurity.SIGN_ENCRYPT.name()) ); @@ -995,13 +995,13 @@ void securedConnectionRelyingOnDiscoveryIsRejected() { @Test void securedConnectionWithoutTrustAnchorIsRejected() { - // No trust-store-file and no server-certificate-file: the driver must fail + // No tls.trust-store and no server-certificate-file: the driver must fail // closed rather than blindly trusting whatever certificate the server presents. String options = params( entry("discovery", "false"), - entry("key-store-file", CLIENT_KEY_STORE.toString().replace("\\", "/")), - entry("key-store-password", "changeit"), - entry("key-store-type", "pkcs12"), + entry("tls.keystore", CLIENT_KEY_STORE.toString().replace("\\", "/")), + entry("tls.keystore-password", "changeit"), + entry("tls.keystore-type", "pkcs12"), entry("security-policy", SecurityPolicy.Basic256Sha256.name()), entry("message-security", MessageSecurity.SIGN.name()) ); @@ -1013,17 +1013,17 @@ void securedConnectionWithoutTrustAnchorIsRejected() { @Test void securedConnectionWithInsecureVerificationConnects() throws Exception { - // With insecure-certificate-verification the driver must use the permissive verifier + // With verification turned off the driver must use the permissive verifier // (it wins over pinning), so trust is not checked. The server certificate is still // supplied because an encrypted policy needs the server's public key to encrypt the // OpenSecureChannel; only its trust verification is bypassed here. String options = params( entry("discovery", "false"), - entry("key-store-file", CLIENT_KEY_STORE.toString().replace("\\", "/")), - entry("key-store-password", "changeit"), - entry("key-store-type", "pkcs12"), + entry("tls.keystore", CLIENT_KEY_STORE.toString().replace("\\", "/")), + entry("tls.keystore-password", "changeit"), + entry("tls.keystore-type", "pkcs12"), entry("server-certificate-file", SERVER_CERTIFICATE.toString().replace("\\", "/")), - entry("insecure-certificate-verification", "true"), + entry("tls.verify", "false"), entry("security-policy", SecurityPolicy.Basic256Sha256.name()), entry("message-security", MessageSecurity.SIGN.name()) ); @@ -1052,9 +1052,9 @@ class LargeCertificates { @MethodSource("org.apache.plc4x.java.opcua.OpcuaPlcDriverTest#getSecuredConnectionSecurityPolicies") public void connectsWith4096BitClientCertificate(SecurityPolicy policy, MessageSecurity messageSecurity) throws Exception { String connectionString = tcpConnectionAddress + PARAM_DIVIDER + params( - entry("key-store-file", CLIENT_KEY_STORE_4096.getAbsoluteFile().toString().replace("\\", "/")), - entry("key-store-password", "changeit"), - entry("key-store-type", "pkcs12"), + entry("tls.keystore", CLIENT_KEY_STORE_4096.getAbsoluteFile().toString().replace("\\", "/")), + entry("tls.keystore-password", "changeit"), + entry("tls.keystore-type", "pkcs12"), entry("server-certificate-file", SERVER_CERTIFICATE.toString().replace("\\", "/")), entry("security-policy", policy.name()), entry("message-security", messageSecurity.name())); @@ -1420,9 +1420,9 @@ private String getConnectionString(SecurityPolicy policy, MessageSecurity messag case Aes128_Sha256_RsaOaep: case Aes256_Sha256_RsaPss: String connectionParams = params( - entry("key-store-file", CLIENT_KEY_STORE.getAbsoluteFile().toString().replace("\\", "/")), // handle windows paths - entry("key-store-password", "changeit"), - entry("key-store-type", "pkcs12"), + entry("tls.keystore", CLIENT_KEY_STORE.getAbsoluteFile().toString().replace("\\", "/")), // handle windows paths + entry("tls.keystore-password", "changeit"), + entry("tls.keystore-type", "pkcs12"), // Pin trust to the server certificate; the driver rejects unknown certs by default. entry("server-certificate-file", SERVER_CERTIFICATE.toString().replace("\\", "/")), entry("security-policy", policy.name()), diff --git a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/ChannelLifetimeTest.java b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/ChannelLifetimeTest.java index 6b6a7f1e5dc..fb8ab1a5e9c 100644 --- a/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/ChannelLifetimeTest.java +++ b/plc4j/drivers/opcua/src/test/java/org/apache/plc4x/java/opcua/context/ChannelLifetimeTest.java @@ -46,7 +46,7 @@ class ChannelLifetimeTest { private static final long REQUESTED = 3_600_000L; - /** The shipped default of {@code min-channel-lifetime}. */ + /** The shipped default of {@code min-channel-lifetime-ms}. */ private static final long MIN = 5_000L; /** @@ -82,7 +82,7 @@ void plausibleRevisionIsHonoured() { /** * The floor must not override the operator. Someone who deliberately configures a short - * {@code channel-lifetime} gets it - the reconciliation only ever declines to go below what was + * {@code channel-lifetime-ms} gets it - the reconciliation only ever declines to go below what was * asked for, never above it. */ @Test @@ -152,7 +152,7 @@ void loweringTheMinimumHonoursTheServer() { * the default is pinned here rather than assumed. */ @Test - @DisplayName("min-channel-lifetime defaults to 5000 ms") + @DisplayName("min-channel-lifetime-ms defaults to 5000 ms") void minimumChannelLifetimeDefaultResolves() throws Exception { OpcuaConfiguration defaults = new ConfigurationFactory().createConfiguration(OpcuaConfiguration.class, ""); @@ -162,10 +162,10 @@ void minimumChannelLifetimeDefaultResolves() throws Exception { } @Test - @DisplayName("min-channel-lifetime can be lowered from the connection string") + @DisplayName("min-channel-lifetime-ms can be lowered from the connection string") void minimumChannelLifetimeCanBeLowered() throws Exception { OpcuaConfiguration lowered = new ConfigurationFactory() - .createConfiguration(OpcuaConfiguration.class, "min-channel-lifetime=250"); + .createConfiguration(OpcuaConfiguration.class, "min-channel-lifetime-ms=250"); assertEquals(250L, lowered.getMinChannelLifetime()); } diff --git a/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/config/OpenProtocolConfiguration.java b/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/config/OpenProtocolConfiguration.java index 6f8601930f2..60b6c7cddb0 100644 --- a/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/config/OpenProtocolConfiguration.java +++ b/plc4j/drivers/open-protocol/src/main/java/org/apache/plc4x/java/openprotocol/config/OpenProtocolConfiguration.java @@ -25,7 +25,7 @@ public class OpenProtocolConfiguration implements Configuration { - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @Description("Maximum time (in milliseconds) to wait for a reply during the Open-Protocol session setup or any per-request exchange.") @IntDefaultValue(10_000) private int requestTimeout; diff --git a/plc4j/drivers/plc4x/src/main/java/org/apache/plc4x/java/plc4x/config/Plc4xConfiguration.java b/plc4j/drivers/plc4x/src/main/java/org/apache/plc4x/java/plc4x/config/Plc4xConfiguration.java index 115bd78d77f..902df85573e 100644 --- a/plc4j/drivers/plc4x/src/main/java/org/apache/plc4x/java/plc4x/config/Plc4xConfiguration.java +++ b/plc4j/drivers/plc4x/src/main/java/org/apache/plc4x/java/plc4x/config/Plc4xConfiguration.java @@ -20,6 +20,7 @@ import org.apache.plc4x.java.spi.config.Configuration; import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; import org.apache.plc4x.java.spi.config.annotations.Description; import org.apache.plc4x.java.spi.config.annotations.defaults.IntDefaultValue; @@ -29,7 +30,7 @@ public class Plc4xConfiguration implements Configuration { @Description("URL-Encoded connection string to use on the proxy side to reach the given PLC.") private String remoteConnectionString; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(5_000) @Description("Default timeout for all types of requests.") private int requestTimeout; @@ -38,6 +39,7 @@ public class Plc4xConfiguration implements Configuration { @Description("Username for authenticating against the PLC4X proxy server. Authentication is mandatory.") private String username; + @Secret @ConfigurationParameter("password") @Description("Password for authenticating against the PLC4X proxy server. Authentication is mandatory.") private String password; diff --git a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/S7HCotpConnection.java b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/S7HCotpConnection.java index daf04a41fb4..84ab4620db9 100644 --- a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/S7HCotpConnection.java +++ b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/S7HCotpConnection.java @@ -101,8 +101,8 @@ public class S7HCotpConnection extends ConnectionBase { * Heartbeat tick interval and wrapper-level failover timeout, both in milliseconds. * Read from {@link S7Configuration#getHaHeartbeatInterval()} / * {@link S7Configuration#getHaFailoverTimeout()} at connection construction so each - * connection can be tuned independently via URL params {@code ?ha-heartbeat-interval=} - * and {@code ?ha-failover-timeout=}. + * connection can be tuned independently via URL params {@code ?ha-heartbeat-interval-ms=} + * and {@code ?ha-failover-timeout-ms=}. */ private final long heartbeatIntervalMs; private final long failoverTimeoutMs; diff --git a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/configuration/S7Configuration.java b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/configuration/S7Configuration.java index 13c6cb9597a..f9360922a0e 100644 --- a/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/configuration/S7Configuration.java +++ b/plc4j/drivers/s7/src/main/java/org/apache/plc4x/java/s7/configuration/S7Configuration.java @@ -51,20 +51,20 @@ public class S7Configuration implements Configuration { @Description("Skip controller-type detection and assume the given type.") protected ControllerType controllerType = ControllerType.ANY; - @ConfigurationParameter("read-timeout") + @ConfigurationParameter("read-timeout-ms") @IntDefaultValue(10000) @Description("Maximum waiting time (in milliseconds) for a single S7 request/response exchange.") protected int readTimeout = 10000; - @ConfigurationParameter("ha-heartbeat-interval") + @ConfigurationParameter("ha-heartbeat-interval-ms") @IntDefaultValue(4000) @Description("S7H dual-path only: interval between heartbeat ticks (in milliseconds). " + "Each tick pings each inner connection so a standby disruption is detected within " - + "interval + ha-failover-timeout. Lower values detect faster but generate more " + + "interval + ha-failover-timeout-ms. Lower values detect faster but generate more " + "background traffic. Default 4000 (4s).") protected int haHeartbeatInterval = 4000; - @ConfigurationParameter("ha-failover-timeout") + @ConfigurationParameter("ha-failover-timeout-ms") @IntDefaultValue(2000) @Description("S7H dual-path only: maximum time (in milliseconds) the wrapper waits for " + "an operation on the active inner before swapping to the alternate. The same value " diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualS7Inventory.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualS7Inventory.java index 0e4295b7e22..76c8b4bdc1c 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualS7Inventory.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualS7Inventory.java @@ -126,7 +126,7 @@ private static InventoryEntry inventory(PlcDiscoveryItem device) { } for (int slot : CANDIDATE_SLOTS) { // The rack/slot parameters belong to the COTP transport, hence the prefix. - String url = String.format("s7://%s?cotp.remote-slot=%d&read-timeout=%d", + String url = String.format("s7://%s?cotp.remote-slot=%d&read-timeout-ms=%d", ipAddress, slot, READ_TIMEOUT_MS); // getConnection() hands back an already-connected connection — connecting again // would re-run the S7 handshake on a live session and fail. diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS71500HFailover.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS71500HFailover.java index 293d1f98807..5785e16ff04 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS71500HFailover.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS71500HFailover.java @@ -55,7 +55,7 @@ public class ManualWallS71500HFailover { /** Primary = S7-1511C-1 PN, Secondary = S7-1516-3 PN/DP. Different models, same family. */ private static final String CONNECTION_URL = - "s7://192.168.24.66/192.168.24.64?local-device-group=PG_OR_PC&remote-rack=0&remote-slot=1&ha-failover-timeout=500&ha-heartbeat-interval=1000"; + "s7://192.168.24.66/192.168.24.64?cotp.local-device-group=PG_OR_PC&cotp.remote-rack=0&cotp.remote-slot=1&ha-failover-timeout-ms=500&ha-heartbeat-interval-ms=1000"; private static final long RUN_DURATION_MS = 120_000L; private static final long READ_INTERVAL_MS = 2_000L; diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300Browse.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300Browse.java index a3eab2b1989..61c6e1816a0 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300Browse.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300Browse.java @@ -32,7 +32,7 @@ public class ManualWallS7300Browse { public static void main(String[] args) throws Exception { long startTime = System.currentTimeMillis(); - try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection("s7://192.168.24.60?local-device-group=OS")){ + try (PlcConnection connection = PlcDriverManager.getDefault().getConnectionFactory().getConnection("s7://192.168.24.60?cotp.local-device-group=OS")){ PlcBrowseResponse plcBrowseResponse = connection.browseRequestBuilder() .addQuery("all", "**") .build().executeWithInterceptor((queryName, query, item) -> { diff --git a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java index ffd9a1b4164..cf5dcba41d7 100644 --- a/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java +++ b/plc4j/drivers/s7/src/test/java/org/apache/plc4x/java/s7/manual/ManualWallS7300DriverTest.java @@ -44,7 +44,7 @@ public ManualWallS7300DriverTest(String connectionString) { public static void main(String[] args) throws Exception { boolean testArrays = false; - ManualWallS7300DriverTest test = new ManualWallS7300DriverTest("s7://192.168.24.60?remote-rack=0&remote-slot=1");//?log.audit-log-file=ManualWallS7300DriverTest-audit.log + ManualWallS7300DriverTest test = new ManualWallS7300DriverTest("s7://192.168.24.60?cotp.remote-rack=0&cotp.remote-slot=1");//?log.audit-log-file=ManualWallS7300DriverTest-audit.log test.addTestCase(/*"g_b1",*/ "%DB42:0.0:BOOL", new PlcBOOL(true)); test.addTestCase(/*"g_b8",*/ "%DB42:1.0:BYTE", new PlcBYTE(0xAB)); test.addTestCase(/*"g_b16",*/ "%DB42:2.0:WORD", new PlcWORD(0xBEEF)); diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java index 65df346fa34..88c27463aea 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/SlmpConnection.java @@ -99,7 +99,7 @@ private void validateConfiguration() throws PlcConnectionException { } int requestTimeout = configuration.getRequestTimeout(); if (requestTimeout <= 0) { - throw new PlcConnectionException("request-timeout must be > 0 ms but was " + requestTimeout + throw new PlcConnectionException("request-timeout-ms must be > 0 ms but was " + requestTimeout + " (a non-positive value would time out every request immediately)"); } } diff --git a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java index 10c1a6d4943..b37becb4b22 100644 --- a/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java +++ b/plc4j/drivers/slmp/src/main/java/org/apache/plc4x/java/slmp/config/SlmpConfiguration.java @@ -25,12 +25,15 @@ public class SlmpConfiguration implements Configuration { + // No "-ms": this is not a duration in milliseconds but a field of the 3E request frame, in + // SLMP's own units, where 0 means "wait infinitely". The name is the protocol's. Renaming it + // to monitoring-timer-ms would state a unit it does not have. @ConfigurationParameter("monitoring-timer") @IntDefaultValue(0x0000) @Description("SLMP monitoring timer written into each 3E request frame (0 = wait infinitely).") private int monitoringTimer; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(5_000) @Description("Client-side timeout in milliseconds awaiting a response.") private int requestTimeout; diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java index cecc6c0249a..7f3789bc1c0 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/SlmpConnectionFailurePathTest.java @@ -151,7 +151,7 @@ void connectRejectsNonPositiveRequestTimeout() { SlmpConnection connection = newDisconnectedConnection(config); assertThrows(PlcConnectionException.class, connection::connect, - "a non-positive request-timeout must be rejected at connect rather than failing every read"); + "a non-positive request-timeout-ms must be rejected at connect rather than failing every read"); } @Test diff --git a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java index 065da4f1140..156e4bb1a0a 100644 --- a/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java +++ b/plc4j/drivers/slmp/src/test/java/org/apache/plc4x/java/slmp/config/SlmpConfigurationTest.java @@ -45,7 +45,7 @@ void defaultsAreApplied() { @Test void overridesAreParsed() { SlmpConfiguration config = new ConfigurationFactory() - .createConfiguration(SlmpConfiguration.class, "monitoring-timer=4&request-timeout=2000"); + .createConfiguration(SlmpConfiguration.class, "monitoring-timer=4&request-timeout-ms=2000"); assertEquals(4, config.getMonitoringTimer()); assertEquals(2_000, config.getRequestTimeout()); } diff --git a/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/configuration/UmasConfiguration.java b/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/configuration/UmasConfiguration.java index 744e7d23827..4bbbbcc048c 100644 --- a/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/configuration/UmasConfiguration.java +++ b/plc4j/drivers/umas/src/main/java/org/apache/plc4x/java/umas/configuration/UmasConfiguration.java @@ -31,7 +31,7 @@ public class UmasConfiguration implements Configuration { @Description("Modbus unit identifier (slave address). UMAS typically uses 0.") private int unitIdentifier; - @ConfigurationParameter("request-timeout") + @ConfigurationParameter("request-timeout-ms") @IntDefaultValue(4000) @Description("Timeout in milliseconds for UMAS requests.") private int requestTimeout; diff --git a/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/SecretParameters.java b/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/SecretParameters.java new file mode 100644 index 00000000000..ae48da3a3f6 --- /dev/null +++ b/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/SecretParameters.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.config; + +import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; + +/** + * The parameter names a configuration class declares as carrying secrets. + * + *

This is what lets redaction be driven by the declaration rather than by a list of words + * matched against names - see {@link Secret} for why that distinction matters.

+ */ +public final class SecretParameters { + + /** Cached per class: the answer cannot change for the lifetime of the class. */ + private static final Map, Set> CACHE = new ConcurrentHashMap<>(); + + private SecretParameters() { + // Utility class. + } + + /** + * The {@link ConfigurationParameter} names of every {@link Secret} field on the given class and + * its supertypes. + * + *

A {@code @Secret} field without a {@code @ConfigurationParameter} contributes nothing here + * - it has no name to match in a connection string - but is still covered by the + * {@code toString()} rule.

+ * + * @param configurationClass the class to inspect; {@code null} yields an empty set + * @return an unmodifiable set of parameter names, never {@code null} + */ + public static Set namesFor(Class configurationClass) { + if (configurationClass == null) { + return Collections.emptySet(); + } + return CACHE.computeIfAbsent(configurationClass, SecretParameters::collect); + } + + /** Every field marked as a secret on the given class and its supertypes, parameter or not. */ + public static Set fieldsOf(Class configurationClass) { + Set fields = new LinkedHashSet<>(); + Class current = configurationClass; + while ((current != null) && (current != Object.class)) { + for (Field field : current.getDeclaredFields()) { + if (!Modifier.isStatic(field.getModifiers()) && field.isAnnotationPresent(Secret.class)) { + fields.add(field); + } + } + current = current.getSuperclass(); + } + return fields; + } + + private static Set collect(Class configurationClass) { + Set names = new LinkedHashSet<>(); + for (Field field : fieldsOf(configurationClass)) { + ConfigurationParameter parameter = field.getAnnotation(ConfigurationParameter.class); + if (parameter == null) { + continue; + } + // A parameter with no explicit name is addressed by its field name, the same rule + // ConfigurationFactory applies when it resolves one. + names.add(parameter.value().isEmpty() ? field.getName() : parameter.value()); + } + return Collections.unmodifiableSet(names); + } +} diff --git a/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/annotations/Secret.java b/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/annotations/Secret.java new file mode 100644 index 00000000000..3b60a8fe9f3 --- /dev/null +++ b/plc4j/spi/config/src/main/java/org/apache/plc4x/java/spi/config/annotations/Secret.java @@ -0,0 +1,76 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.config.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Marks a configuration field as carrying a secret - a password, a pre-shared key, a token, or any + * other value that must never reach a log file, an audit-log entry, an exception message or a + * {@code toString()} rendering. + * + *

Annotate at the point of definition. This annotation is the single source of + * truth for "is this value sensitive?". It replaces a pattern in {@code DriverBase} that guessed + * from the parameter name ({@code password|passwd|secret|token|psk-key|passphrase}). That list + * lived one module away from every declaration it protected, and could only ever be one parameter + * behind - {@code psk-key} had to be added to it after the fact. A declaration that travels with + * the parameter cannot drift the way a list in another module does.

+ * + *

What it drives:

+ *
    + *
  1. Connection-string redaction before logging, which derives the set of secret parameter + * names from the annotated fields of the driver's and the transports' configuration + * classes.
  2. + *
  3. {@code toString()} - an annotated field must render as {@code }, never as its + * value. {@link org.apache.plc4x.java.spi.config.SecretParameters#fieldsOf(Class)} gives a renderer the fields to mask. + * This one is a rule, not yet an enforcement. Nothing in the build plants a + * sentinel in every annotated field of every configuration and fails if it surfaces: no + * plc4j module both sees every driver's configuration classes and runs tests, so such a + * sweep has nowhere to live. Until it does, a hand-written {@code toString()} that renders + * a newly marked field verbatim will not be caught here. PLC4Go's equivalent leak test is + * per-driver for the same reason.
  4. + *
+ * + *

What is not a secret. An identifier that says which credential was + * used is not one: {@code psk-identity} tells an operator which key the device refused, and hiding + * it costs them the diagnosis while protecting nothing. Nor are store types, file paths, buffer + * sizes or booleans. Marking everything credential-adjacent makes logs useless without making + * anything safer.

+ * + *

Placement: on the field, beside {@link ConfigurationParameter}. A field + * without a {@code @ConfigurationParameter} may still be annotated - it is then covered by the + * {@code toString()} rule but contributes no parameter name, because there is nothing to match in + * a connection string.

+ * + *

Limits - read before assuming coverage. This marks a named parameter. + * A credential that reaches a driver by another route is not covered: the clear case is URI + * userinfo ({@code s7://user:password@plc:102}), where the secret is part of the authority + * component and has no parameter name at all. That is redacted structurally, independent of any + * annotation.

+ * + * @see ConfigurationParameter + * @see org.apache.plc4x.java.spi.config.SecretParameters + */ +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface Secret { +} diff --git a/plc4j/spi/config/src/test/java/org/apache/plc4x/java/spi/config/SecretParametersTest.java b/plc4j/spi/config/src/test/java/org/apache/plc4x/java/spi/config/SecretParametersTest.java new file mode 100644 index 00000000000..6bb7d922da0 --- /dev/null +++ b/plc4j/spi/config/src/test/java/org/apache/plc4x/java/spi/config/SecretParametersTest.java @@ -0,0 +1,93 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.config; + +import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; +import org.junit.jupiter.api.Test; + +import java.util.Set; + +import static org.junit.jupiter.api.Assertions.*; + +class SecretParametersTest { + + static class Credentials implements Configuration { + @Secret + @ConfigurationParameter("password") + public String password; + + @ConfigurationParameter("username") + public String username; + + /** Marked, but not a parameter: covered by the toString rule, nothing to match in a URL. */ + @Secret + public String derivedKey; + + /** A parameter with no explicit name is addressed by its field name. */ + @Secret + @ConfigurationParameter + public String sessionToken; + } + + static class InheritedCredentials extends Credentials { + @Secret + @ConfigurationParameter("psk-key") + public String pskKey; + } + + @Test + void reportsTheNamesOfMarkedParameters() { + assertEquals(Set.of("password", "sessionToken"), SecretParametersTest.namesOf(Credentials.class)); + } + + @Test + void leavesUnmarkedParametersAlone() { + assertFalse(SecretParameters.namesFor(Credentials.class).contains("username")); + } + + @Test + void aMarkedFieldThatIsNotAParameterContributesNoName() { + // It has no name to match in a connection string; the toString rule still covers it. + assertFalse(SecretParameters.namesFor(Credentials.class).contains("derivedKey")); + assertTrue(SecretParameters.fieldsOf(Credentials.class).stream() + .anyMatch(field -> field.getName().equals("derivedKey"))); + } + + @Test + void includesInheritedMarkings() { + Set names = SecretParameters.namesFor(InheritedCredentials.class); + assertTrue(names.contains("psk-key"), "its own"); + assertTrue(names.contains("password"), "and its parent's"); + } + + @Test + void handlesAClassWithNoMarkings() { + assertTrue(SecretParameters.namesFor(String.class).isEmpty()); + } + + @Test + void handlesNull() { + assertTrue(SecretParameters.namesFor(null).isEmpty()); + } + + private static Set namesOf(Class type) { + return SecretParameters.namesFor(type); + } +} diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionStringRedactor.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionStringRedactor.java new file mode 100644 index 00000000000..75e24c37aeb --- /dev/null +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/ConnectionStringRedactor.java @@ -0,0 +1,194 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers; + +import org.apache.plc4x.java.spi.config.SecretParameters; +import org.apache.plc4x.java.spi.config.annotations.Secret; + +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Locale; +import java.util.LinkedHashSet; +import java.util.Set; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +/** + * Removes secrets from a connection string before it is logged. + * + *

Which parameters are secret comes from the {@link Secret} markings on the configuration + * classes involved, not from matching words against parameter names. The name-matching version + * that preceded this could only ever be one parameter behind: it lived in {@code DriverBase}, + * modules away from every declaration it protected, and had to be extended by hand each time + * somebody noticed a value in a log.

+ * + *

Credentials in a URI's userinfo ({@code s7://user:secret@plc:102}) have no parameter name to + * mark, so they are removed structurally - see {@link Secret}'s note on what the marking does not + * cover.

+ * + *

A name-based backstop remains, demoted. The markings decide what a + * configuration declares; they cannot decide anything about a parameter no configuration declares. + * A user who misspells a secret parameter - {@code passwrod=hunter2}, or an {@code api-token} some + * deployment passes through - has still typed a real credential into a string that is about to be + * logged, and the marking has nothing to match it against. So a parameter whose name looks like a + * secret is redacted too. This is no longer the source of truth, only a net under it, and it is + * why the list may be conservative without being complete.

+ */ +public final class ConnectionStringRedactor { + + /** What a redacted value is replaced with. Matches what the driver logs elsewhere. */ + private static final String REDACTED = "******"; + + /** + * The credentials of a URI authority: everything between the first {@code :} after the + * scheme's {@code //} and the {@code @} that ends the userinfo component. + * + *

The user segment excludes {@code :} so that the first colon separates it from + * the password. A greedy user segment would let a password containing a colon keep everything + * up to its last one - {@code bob:pa:ss@} would be redacted to {@code bob:pa:******@}, which + * publishes half the credential while looking like it hid it.

+ */ + private static final Pattern USERINFO = Pattern.compile("(//)([^/@\\s:]*:)([^/@\\s]*)(@)"); + + private ConnectionStringRedactor() { + // Utility class. + } + + /** + * The connection string with every secret value replaced. + * + * @param connectionString the string to redact; {@code null} yields {@code null} + * @param driverConfigurationClass the driver's configuration class, or {@code null} + * @param transportCode the transport's code, which prefixes its parameters + * @param transportConfigurationClass the transport's configuration class, or {@code null} + */ + public static String redact(String connectionString, Class driverConfigurationClass, + String transportCode, Class transportConfigurationClass) { + if (connectionString == null) { + return null; + } + Set secretNames = new LinkedHashSet<>(); + for (String name : secretParameterNames(driverConfigurationClass, transportCode, transportConfigurationClass)) { + secretNames.add(name.toLowerCase(Locale.ROOT)); + } + return redactParameters(redactUserinfo(connectionString), secretNames, true); + } + + /** + * Walks the parameters once, deciding each from its decoded name. + * + *

Deciding from the name as written would miss a percent-encoded one: + * {@code ?%70assword=hunter2} is the {@code password} parameter by the time + * {@code URI.getQuery()} has decoded it and the driver reads it, but no pattern over the raw + * string sees the word. The value is a real credential either way.

+ */ + private static String redactParameters(String connectionString, Set secretNames, + boolean redactNestedValues) { + Matcher matcher = PARAMETER.matcher(connectionString); + StringBuilder redacted = new StringBuilder(); + while (matcher.find()) { + String name = decode(matcher.group(2)); + String value = isSecret(name, secretNames) ? REDACTED + : redactNestedValues ? redactNested(matcher.group(3)) : matcher.group(3); + matcher.appendReplacement(redacted, + Matcher.quoteReplacement(matcher.group(1) + matcher.group(2) + "=" + value)); + } + matcher.appendTail(redacted); + return redacted.toString(); + } + + /** + * A parameter whose value is itself a connection string carries that string's credentials. + * + *

The PLC4X proxy driver takes a whole PLC URL as {@code remote-connection-string}, encoded + * so the outer string can hold it. Nothing about the outer parameter's name says "secret", and + * the encoded value hides the inner {@code password=} from every pattern here - so the inner + * credential was logged in clear. Redacting the value as a connection string in its own right + * keeps what an operator needs (which PLC the proxy talks to) and removes what they must not + * see, which marking the whole parameter secret would not.

+ */ + private static String redactNested(String value) { + String decoded; + try { + decoded = URLDecoder.decode(value, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return value; + } + if (!decoded.contains("://")) { + return value; + } + // One level: a connection string inside a connection string is the case that exists, and + // a bound depth cannot be talked into recursing on crafted input. + String redacted = redactParameters(redactUserinfo(decoded), Set.of(), false); + if (decoded.equals(redacted)) { + return value; + } + // Rendered back the way it was written, so the log line still looks like the string the + // user supplied. + return decoded.equals(value) ? redacted : URLEncoder.encode(redacted, StandardCharsets.UTF_8); + } + + private static boolean isSecret(String name, Set secretNames) { + return secretNames.contains(name.toLowerCase(Locale.ROOT)) + || SECRET_LOOKING_NAME.matcher(name).find(); + } + + /** The name as the driver will read it, or as written when it is not valid encoding. */ + private static String decode(String name) { + try { + return URLDecoder.decode(name, StandardCharsets.UTF_8); + } catch (IllegalArgumentException e) { + return name; + } + } + + /** + * Every parameter name that carries a secret, with the transport's parameters under its code - + * a transport parameter is addressed as {@code tls.psk-key}, so that is the name to look for. + */ + static Set secretParameterNames(Class driverConfigurationClass, String transportCode, + Class transportConfigurationClass) { + Set names = new LinkedHashSet<>(SecretParameters.namesFor(driverConfigurationClass)); + for (String name : SecretParameters.namesFor(transportConfigurationClass)) { + names.add(((transportCode == null) ? "" : transportCode + ".") + name); + } + return names; + } + + /** + * Names that read like a credential. Only consulted for parameters the configurations do not + * declare - see the class comment. {@code psk-identity} is deliberately absent: it says which + * key was refused, which is the one thing an operator needs when a handshake fails. + */ + private static final Pattern SECRET_LOOKING_NAME = Pattern.compile( + "(?i)password|passwd|secret|token|psk-key|passphrase"); + + /** + * One parameter of a connection string: its separator, its name as written, and its value. + * Names are compared without regard to case - a wrongly-cased parameter does not bind, but + * the value the user typed is a real secret either way and must not reach the log. + */ + private static final Pattern PARAMETER = Pattern.compile("([?&])([^=&]*)=([^&]*)"); + + private static String redactUserinfo(String connectionString) { + Matcher matcher = USERINFO.matcher(connectionString); + return matcher.replaceAll("$1$2" + REDACTED + "$4"); + } +} diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java index e4621b12230..2023023b8e8 100644 --- a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/DriverBase.java @@ -29,7 +29,6 @@ import org.apache.plc4x.java.api.types.OptionType; import org.apache.plc4x.java.spi.config.Configuration; import org.apache.plc4x.java.spi.config.ConfigurationFactory; -import org.apache.plc4x.java.spi.config.annotations.ComplexConfigurationParameter; import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; import org.apache.plc4x.java.spi.config.annotations.Description; import org.apache.plc4x.java.spi.config.annotations.Required; @@ -69,8 +68,6 @@ import java.util.regex.Matcher; import java.util.regex.Pattern; -import static java.util.stream.Collectors.toList; - /** * Abstract base class for PLC4X drivers. *

@@ -82,20 +79,6 @@ public abstract class DriverBase implements PlcDriver { public static final Pattern URI_PATTERN = Pattern.compile( "^(?[a-z0-9\\-]*)(:(?[a-z0-9\\-]*))?://(?[^?]*)(\\?(?.*))?"); - /** - * Matches the value of connection-string query parameters that carry secrets so they can be - * masked before logging. Covers any parameter whose name contains "password", "passwd", - * "secret", "token", "psk-key" or "passphrase" (optionally with a transport prefix like - * "tls."). - * - *

"psk-key" is named in full rather than matching "key" or "psk" alone, which would take - * "key-store-file", "key-store-type", "generated-key-size", "log-session-keys" and - * "psk-identity" with it. Those are a path, a store type, a number, a boolean and an - * identifier - masking them protects nothing and costs an operator the diagnosis, the identity - * most of all, since it says which key was refused.

- */ - private static final Pattern SECRET_PARAM_PATTERN = Pattern.compile( - "(?i)([?&][^=&]*(?:password|passwd|secret|token|psk-key|passphrase)[^=&]*=)[^&]*"); private static final Logger log = LoggerFactory.getLogger(DriverBase.class); @@ -171,8 +154,6 @@ public PlcConnection getConnection(String connectionString, PlcAuthentication pl throw new PlcConnectionException( "Connection string doesn't match the format '{protocol-code}(:{transport-code})?://{transport-config}(?{parameter-string)?'"); } - log.info("Using connection string: {}", redactSecrets(connectionString)); - final String protocolCode = matcher.group("protocolCode"); String transportCodeMatch = matcher.group("transportCode"); if (transportCodeMatch == null && getMetadata().getDefaultTransportCode().isEmpty()) { @@ -229,6 +210,13 @@ public PlcConnection getConnection(String connectionString, PlcAuthentication pl // no field for, so a misspelt or misplaced parameter looks accepted and silently leaves the // default in place - 'remote-slot=2' instead of 'cotp.remote-slot=2' being the classic case. warnAboutUnknownParameters(paramString, transportCode, transportConfigType); + + // Logged here rather than on entry: the transport's secrets - a PSK key, a keystore + // password - are declared on its configuration class, which is only known once the + // transport has been resolved. Logging earlier would mean redacting without knowing what + // half the secrets are called. + log.info("Using connection string: {}", ConnectionStringRedactor.redact( + connectionString, getConfigurationClass(), transportCode, transportConfigType)); this.auditLog = AuditLog.builder() .withSource(getProtocolCode()) .withConfiguration(auditLogConfiguration) @@ -266,12 +254,6 @@ public PlcConnection getConnection(String connectionString, PlcAuthentication pl * Masks the values of secret-bearing query parameters (passwords, tokens, …) in a connection * string so credentials never reach the logs. */ - static String redactSecrets(String connectionString) { - if (connectionString == null) { - return null; - } - return SECRET_PARAM_PATTERN.matcher(connectionString).replaceAll("$1***"); - } public AuditLog getAuditLog() { return auditLog; @@ -326,156 +308,27 @@ public boolean isDiscoverySupported() { /** * Logs a warning for every parameter in the connection string that none of the configurations - * involved declares, listing what was expected. - *

- * This is deliberately a warning and not an exception: the driver, the transport, the audit log - * and the connection control options are the configurations this class knows about, but a driver - * is free to read further prefixed configurations of its own, and those parameters would look - * unknown from here. Failing the connection over that would break working setups; saying - * something turns a silent misconfiguration into a visible one. + * involved declares. + * + *

The logic lives in {@link UnknownParameterReporter}, which is public so the one driver + * that implements {@code PlcDriver} directly rather than extending this class can report too. + * The two package-private methods below stay as delegates because this class's tests drive + * them directly, and keeping them is what shows the extraction changed no behaviour.

*/ private void warnAboutUnknownParameters(String paramString, String transportCode, Class transportConfigType) { - List unknown = findUnknownParameters( - paramString, getConfigurationClass(), transportConfigType, transportCode); - if (unknown.isEmpty()) { - return; - } - Set known = knownParameterNames(getConfigurationClass(), transportConfigType, transportCode); - for (String name : unknown) { - String suggestion = suggestionFor(name, known); - if (suggestion == null) { - log.warn("Connection string parameter '{}' is not known to driver '{}' and is ignored.", - name, getProtocolCode()); - } else { - log.warn("Connection string parameter '{}' is not known to driver '{}' and is ignored - " - + "did you mean '{}'?", name, getProtocolCode(), suggestion); - } - } - if (log.isDebugEnabled()) { - List sorted = new ArrayList<>(known); - Collections.sort(sorted); - log.debug("Parameters driver '{}' accepts over transport '{}': {}", - getProtocolCode(), transportCode, sorted); - } + UnknownParameterReporter.report( + getProtocolCode(), paramString, transportCode, getConfigurationClass(), transportConfigType); } - /** - * The known parameter the given unknown one was most likely meant to be, or {@code null} when - * nothing is close enough to be worth suggesting. - *

- * A missing or wrong prefix is the mistake that actually happens - {@code remote-slot} for - * {@code cotp.remote-slot} - so a name that matches some known parameter's last segment wins - * outright. Otherwise a short edit distance catches ordinary typos. - */ - static String suggestionFor(String unknown, Set known) { - String unknownLeaf = leafOf(unknown); - List byLeaf = known.stream() - .filter(candidate -> !candidate.equals(unknown) && leafOf(candidate).equals(unknownLeaf)) - .sorted() - .collect(toList()); - if (!byLeaf.isEmpty()) { - return byLeaf.get(0); - } - - // Allow roughly one edit per four characters, so short names don't match everything. - int budget = Math.min(3, Math.max(1, unknown.length() / 4)); - String best = null; - int bestDistance = Integer.MAX_VALUE; - for (String candidate : known) { - int distance = editDistance(unknown, candidate); - if (distance <= budget && ((best == null) || (distance < bestDistance) - || ((distance == bestDistance) && (candidate.compareTo(best) < 0)))) { - best = candidate; - bestDistance = distance; - } - } - return best; - } - - /** The part after the last dot - the parameter name without any prefix. */ - private static String leafOf(String name) { - int lastDot = name.lastIndexOf('.'); - return (lastDot < 0) ? name : name.substring(lastDot + 1); - } - - /** - * Levenshtein distance. Hand-rolled to keep the SPI free of another dependency; the strings - * involved are parameter names, so the quadratic cost is irrelevant. - */ - private static int editDistance(String left, String right) { - int[] previous = new int[right.length() + 1]; - int[] current = new int[right.length() + 1]; - for (int j = 0; j <= right.length(); j++) { - previous[j] = j; - } - for (int i = 1; i <= left.length(); i++) { - current[0] = i; - for (int j = 1; j <= right.length(); j++) { - int substitution = previous[j - 1] + (left.charAt(i - 1) == right.charAt(j - 1) ? 0 : 1); - current[j] = Math.min(substitution, Math.min(previous[j] + 1, current[j - 1] + 1)); - } - int[] swap = previous; - previous = current; - current = swap; - } - return previous[right.length()]; - } - - /** - * The parameters in {@code paramString} that none of the configurations involved declares, in the - * order they were supplied. Package-private so it can be tested without a registered transport. - */ static List findUnknownParameters(String paramString, Class driverConfigClass, Class transportConfigClass, String transportCode) { - Set supplied = ConfigurationFactory.parameterNames(paramString); - if (supplied.isEmpty()) { - return Collections.emptyList(); - } - Set known = knownParameterNames(driverConfigClass, transportConfigClass, transportCode); - return supplied.stream().filter(name -> !known.contains(name)).collect(toList()); + return UnknownParameterReporter.findUnknownParameters( + paramString, driverConfigClass, transportConfigClass, transportCode); } - private static Set knownParameterNames(Class driverConfigClass, Class transportConfigClass, - String transportCode) { - Set known = new HashSet<>(); - collectParameterNames(driverConfigClass, "", known); - collectParameterNames(transportConfigClass, transportCode + ".", known); - collectParameterNames(AuditLogConfiguration.class, "log.", known); - collectParameterNames(ConnectionControlConfiguration.class, "", known); - return known; - } - - /** - * Adds every parameter name the given configuration class declares to {@code names}, prefixed - * with {@code prefix}. Complex parameters contribute their nested names under their own prefix, - * so an OPC UA {@code encoding.*} parameter is recognised rather than reported as unknown. - */ - private static void collectParameterNames(Class configurationClass, String prefix, Set names) { - if (configurationClass == null) { - return; - } - Class current = configurationClass; - while (current != null && current != Object.class) { - for (Field field : current.getDeclaredFields()) { - if (Modifier.isStatic(field.getModifiers())) { - continue; - } - ConfigurationParameter parameterAnnotation = field.getAnnotation(ConfigurationParameter.class); - if (parameterAnnotation != null && !parameterAnnotation.value().isEmpty()) { - names.add(prefix + parameterAnnotation.value()); - continue; - } - ComplexConfigurationParameter complexAnnotation = - field.getAnnotation(ComplexConfigurationParameter.class); - if (complexAnnotation != null) { - String nestedPrefix = complexAnnotation.prefix().isEmpty() - ? prefix : prefix + complexAnnotation.prefix() + "."; - collectParameterNames(field.getType(), nestedPrefix, names); - } - } - current = current.getSuperclass(); - } + static String suggestionFor(String unknown, Set known) { + return UnknownParameterReporter.suggestionFor(unknown, known); } /** diff --git a/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/UnknownParameterReporter.java b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/UnknownParameterReporter.java new file mode 100644 index 00000000000..266bdb1d095 --- /dev/null +++ b/plc4j/spi/drivers/src/main/java/org/apache/plc4x/java/spi/drivers/UnknownParameterReporter.java @@ -0,0 +1,213 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers; + +import org.apache.plc4x.java.spi.config.ConfigurationFactory; +import org.apache.plc4x.java.spi.config.annotations.ComplexConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.drivers.config.ConnectionControlConfiguration; +import org.apache.plc4x.java.utils.auditlog.api.config.AuditLogConfiguration; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.lang.reflect.Field; +import java.lang.reflect.Modifier; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import static java.util.stream.Collectors.toList; + +/** + * Reports connection-string parameters that no configuration involved declares. + * + *

This lived inside {@link DriverBase} and was private, so the one driver that implements + * {@code PlcDriver} directly - CtrlX - got no reporting at all. The logic is unchanged; it moved + * here so both entry points can use it, and {@code DriverBase} delegates.

+ * + *

One connection string feeds several consumers: the driver configuration, the transport + * configuration under its code, the audit log under {@code log.} and the connection-control + * options. A parameter belonging to any one of them looks unknown to the others, so the check is + * only correct once every consumer has been accounted for - which is why it takes the + * configuration classes rather than reading them from somewhere.

+ */ +public final class UnknownParameterReporter { + + private static final Logger log = LoggerFactory.getLogger(UnknownParameterReporter.class); + + private UnknownParameterReporter() { + // Utility class. + } + + /** + * Logs a warning for every parameter in the connection string that none of the configurations + * involved declares, listing what was expected. + *

+ * This is deliberately a warning and not an exception: the driver, the transport, the audit log + * and the connection control options are the configurations this class knows about, but a driver + * is free to read further prefixed configurations of its own, and those parameters would look + * unknown from here. Failing the connection over that would break working setups; saying + * something turns a silent misconfiguration into a visible one. + */ + public static void report(String protocolCode, String paramString, String transportCode, + Class driverConfigType, Class transportConfigType) { + List unknown = findUnknownParameters( + paramString, driverConfigType, transportConfigType, transportCode); + if (unknown.isEmpty()) { + return; + } + Set known = knownParameterNames(driverConfigType, transportConfigType, transportCode); + for (String name : unknown) { + String suggestion = suggestionFor(name, known); + if (suggestion == null) { + log.warn("Connection string parameter '{}' is not known to driver '{}' and is ignored.", + name, protocolCode); + } else { + log.warn("Connection string parameter '{}' is not known to driver '{}' and is ignored - " + + "did you mean '{}'?", name, protocolCode, suggestion); + } + } + if (log.isDebugEnabled()) { + List sorted = new ArrayList<>(known); + Collections.sort(sorted); + log.debug("Parameters driver '{}' accepts over transport '{}': {}", + protocolCode, transportCode, sorted); + } + } + + /** + * The known parameter the given unknown one was most likely meant to be, or {@code null} when + * nothing is close enough to be worth suggesting. + *

+ * A missing or wrong prefix is the mistake that actually happens - {@code remote-slot} for + * {@code cotp.remote-slot} - so a name that matches some known parameter's last segment wins + * outright. Otherwise a short edit distance catches ordinary typos. + */ + public static String suggestionFor(String unknown, Set known) { + String unknownLeaf = leafOf(unknown); + List byLeaf = known.stream() + .filter(candidate -> !candidate.equals(unknown) && leafOf(candidate).equals(unknownLeaf)) + .sorted() + .toList(); + if (!byLeaf.isEmpty()) { + return byLeaf.getFirst(); + } + + // Allow roughly one edit per four characters, so short names don't match everything. + int budget = Math.clamp(unknown.length() / 4, 1, 3); + String best = null; + int bestDistance = Integer.MAX_VALUE; + for (String candidate : known) { + int distance = editDistance(unknown, candidate); + if (distance <= budget && ((best == null) || (distance < bestDistance) + || ((distance == bestDistance) && (candidate.compareTo(best) < 0)))) { + best = candidate; + bestDistance = distance; + } + } + return best; + } + + /** The part after the last dot - the parameter name without any prefix. */ + private static String leafOf(String name) { + int lastDot = name.lastIndexOf('.'); + return (lastDot < 0) ? name : name.substring(lastDot + 1); + } + + /** + * Levenshtein distance. Hand-rolled to keep the SPI free of another dependency; the strings + * involved are parameter names, so the quadratic cost is irrelevant. + */ + private static int editDistance(String left, String right) { + int[] previous = new int[right.length() + 1]; + int[] current = new int[right.length() + 1]; + for (int j = 0; j <= right.length(); j++) { + previous[j] = j; + } + for (int i = 1; i <= left.length(); i++) { + current[0] = i; + for (int j = 1; j <= right.length(); j++) { + int substitution = previous[j - 1] + (left.charAt(i - 1) == right.charAt(j - 1) ? 0 : 1); + current[j] = Math.min(substitution, Math.min(previous[j] + 1, current[j - 1] + 1)); + } + int[] swap = previous; + previous = current; + current = swap; + } + return previous[right.length()]; + } + + /** + * The parameters in {@code paramString} that none of the configurations involved declares, in the + * order they were supplied. Package-private so it can be tested without a registered transport. + */ + public static List findUnknownParameters(String paramString, Class driverConfigClass, + Class transportConfigClass, String transportCode) { + Set supplied = ConfigurationFactory.parameterNames(paramString); + if (supplied.isEmpty()) { + return Collections.emptyList(); + } + Set known = knownParameterNames(driverConfigClass, transportConfigClass, transportCode); + return supplied.stream().filter(name -> !known.contains(name)).collect(toList()); + } + + private static Set knownParameterNames(Class driverConfigClass, Class transportConfigClass, + String transportCode) { + Set known = new HashSet<>(); + collectParameterNames(driverConfigClass, "", known); + collectParameterNames(transportConfigClass, transportCode + ".", known); + collectParameterNames(AuditLogConfiguration.class, "log.", known); + collectParameterNames(ConnectionControlConfiguration.class, "", known); + return known; + } + + /** + * Adds every parameter name the given configuration class declares to {@code names}, prefixed + * with {@code prefix}. Complex parameters contribute their nested names under their own prefix, + * so an OPC UA {@code encoding.*} parameter is recognised rather than reported as unknown. + */ + private static void collectParameterNames(Class configurationClass, String prefix, Set names) { + if (configurationClass == null) { + return; + } + Class current = configurationClass; + while (current != null && current != Object.class) { + for (Field field : current.getDeclaredFields()) { + if (Modifier.isStatic(field.getModifiers())) { + continue; + } + ConfigurationParameter parameterAnnotation = field.getAnnotation(ConfigurationParameter.class); + if (parameterAnnotation != null && !parameterAnnotation.value().isEmpty()) { + names.add(prefix + parameterAnnotation.value()); + continue; + } + ComplexConfigurationParameter complexAnnotation = + field.getAnnotation(ComplexConfigurationParameter.class); + if (complexAnnotation != null) { + String nestedPrefix = complexAnnotation.prefix().isEmpty() + ? prefix : prefix + complexAnnotation.prefix() + "."; + collectParameterNames(field.getType(), nestedPrefix, names); + } + } + current = current.getSuperclass(); + } + } +} diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/ConnectionStringRedactorTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/ConnectionStringRedactorTest.java new file mode 100644 index 00000000000..c625ae0a152 --- /dev/null +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/ConnectionStringRedactorTest.java @@ -0,0 +1,227 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one + * or more contributor license agreements. See the NOTICE file + * distributed with this work for additional information + * regarding copyright ownership. The ASF licenses this file + * to you under the Apache License, Version 2.0 (the + * "License"); you may not use this file except in compliance + * with the License. You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the License is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY + * KIND, either express or implied. See the License for the + * specific language governing permissions and limitations + * under the License. + */ +package org.apache.plc4x.java.spi.drivers; + +import org.apache.plc4x.java.spi.config.Configuration; +import org.apache.plc4x.java.spi.config.annotations.ConfigurationParameter; +import org.apache.plc4x.java.spi.config.annotations.Secret; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +/** + * What is redacted is decided by the {@link Secret} markings on the configuration classes, with a + * name-based backstop for parameters no configuration declares. + */ +class ConnectionStringRedactorTest { + + private static final String REDACTED = "******"; + private static final String NESTED = "s7%3A%2F%2Foperator%3Ahunter2%40plc%3A102%3Fpassword%3Dhunter2"; + + static class DriverConfig implements Configuration { + @Secret + @ConfigurationParameter("password") + public String password; + + @ConfigurationParameter("username") + public String username; + + @ConfigurationParameter("remote-connection-string") + public String remoteConnectionString; + } + + static class TlsConfig implements Configuration { + @Secret + @ConfigurationParameter("keystore-password") + public String keystorePassword; + + @ConfigurationParameter("keystore") + public String keystore; + + @ConfigurationParameter("verify") + public boolean verifySsl; + } + + static class PskConfig implements Configuration { + @Secret + @ConfigurationParameter("psk-key") + public String pskKey; + + // Not marked: the identity says which key was refused. + @ConfigurationParameter("psk-identity") + public String pskIdentity; + } + + private static String redact(String url) { + return ConnectionStringRedactor.redact(url, DriverConfig.class, "tls", TlsConfig.class); + } + + @Test + @DisplayName("masks a marked parameter's value") + void masksAMarkedParameter() { + assertEquals( + "plc4x:tls://host:59837?remote-connection-string=s7&username=op&password=******&tls.verify=false", + redact("plc4x:tls://host:59837?remote-connection-string=s7&username=op&password=hunter2&tls.verify=false")); + } + + @Test + @DisplayName("masks a marked parameter that leads the query string") + void masksALeadingParameter() { + assertEquals("plc4x://host?password=******&username=op", + redact("plc4x://host?password=hunter2&username=op")); + } + + @Test + @DisplayName("masks a transport parameter under the transport's prefix") + void masksATransportParameterUnderItsPrefix() { + assertEquals("plc4x:tls://host?password=******&tls.keystore-password=******", + redact("plc4x:tls://host?password=abc&tls.keystore-password=def")); + } + + @Test + @DisplayName("masks a pre-shared key, whose name no word list would catch") + void masksAPreSharedKey() { + // The reason the marking exists: this name contains none of password, secret or token, + // and it is the credential for the whole connection. + assertEquals("plc4x:tls-psk://host?tls-psk.psk-identity=plc4x&tls-psk.psk-key=******", + ConnectionStringRedactor.redact( + "plc4x:tls-psk://host?tls-psk.psk-identity=plc4x&tls-psk.psk-key=0011deadbeef", + DriverConfig.class, "tls-psk", PskConfig.class)); + } + + @Test + @DisplayName("leaves unmarked parameters untouched") + void leavesUnmarkedParametersUntouched() { + String url = "plc4x:tls://host:59837?remote-connection-string=s7&username=op&tls.verify=false"; + assertEquals(url, redact(url)); + } + + @Test + @DisplayName("leaves the things named like keys that are not keys") + void leavesKeyShapedNonSecretsUntouched() { + // Masking these would cost an operator the diagnosis and protect nothing: a path, a store + // type, a key size, a boolean, and the identity that says *which* key failed. + String url = "plc4x:tls://host?keystore=/etc/plc4x/client.p12" + + "&keystore-type=pkcs12&generated-key-size=2048&log-session-keys=false" + + "&tls-psk.psk-identity=plc4x&allow-insecure-credentials=false"; + assertEquals(url, redact(url)); + } + + @Test + @DisplayName("does not match a parameter that merely looks similar") + void doesNotMaskUsername() { + assertEquals("plc4x://host?username=secretive-bob", redact("plc4x://host?username=secretive-bob")); + } + + @Test + @DisplayName("masks a marked parameter whose case does not match the declaration") + void masksAWronglyCasedParameter() { + // It would not bind - and is reported as unknown - but the user typed a real password, and + // it must not reach the log on its way to being ignored. + assertEquals("plc4x://host?PassWord=******", redact("plc4x://host?PassWord=hunter2")); + } + + @Test + @DisplayName("masks a secret-looking parameter no configuration declares") + void masksAnUndeclaredSecretLookingParameter() { + // The backstop. The markings can say nothing about a parameter nothing declares, and a + // credential passed under a name this build does not know is still a credential. + assertEquals("plc4x://host?api-token=******&passphrase=******&my-password=******", + redact("plc4x://host?api-token=abc&passphrase=open-sesame&my-password=hunter2")); + } + + @Test + @DisplayName("the backstop reads names, so a misspelling it cannot recognise gets through") + void doesNotCatchAMisspellingThatLooksLikeNothing() { + // Worth stating rather than discovering: the backstop matches names *containing* a + // credential word. "passwrod" contains none, so nothing can tell it from any other + // unknown parameter, and its value is logged. The unknown-parameter warning names it, + // which is the only signal available - a marking cannot exist for a name nobody declared. + assertEquals("plc4x://host?passwrod=hunter2", redact("plc4x://host?passwrod=hunter2")); + } + + @Test + @DisplayName("masks credentials in the URI's userinfo, which have no parameter name") + void masksUserinfoCredentials() { + assertEquals("s7://operator:******@plc:102?username=op", + redact("s7://operator:hunter2@plc:102?username=op")); + } + + @Test + @DisplayName("masks a userinfo password that contains a colon") + void masksUserinfoCredentialsContainingAColon() { + // A colon is legal inside a password. The user segment stops at the first colon, so + // everything after it is the password; a greedy one would publish "operator:hun". + assertEquals("s7://operator:******@plc:102", + redact("s7://operator:hun:ter2@plc:102")); + } + + @Test + @DisplayName("masks a percent-encoded parameter name") + void masksAPercentEncodedName() { + // The driver reads this as "password" once the query is decoded, so the value is a + // credential however the name was written. + assertEquals("plc4x://host?%70assword=" + REDACTED, + redact("plc4x://host?%70assword=hunter2")); + } + + @Test + @DisplayName("masks credentials nested in a connection string passed as a value") + void masksCredentialsInsideANestedConnectionString() { + // The PLC4X proxy driver takes a whole PLC URL as a parameter. Nothing about the outer + // name says "secret", and the encoding hides the inner one from every pattern. + String redacted = redact("plc4x://proxy?remote-connection-string=" + NESTED); + + assertFalse(redacted.contains("hunter2"), redacted); + assertTrue(redacted.contains("remote-connection-string="), "the parameter is still named"); + } + + @Test + @DisplayName("keeps a nested connection string readable apart from its credentials") + void keepsTheNestedEndpointVisible() { + String redacted = redact("plc4x://proxy?remote-connection-string=s7://operator:hunter2@plc:102"); + + assertFalse(redacted.contains("hunter2"), redacted); + assertTrue(redacted.contains("plc:102"), "which PLC the proxy talks to is the diagnosis"); + } + + @Test + @DisplayName("leaves a connection string without parameters untouched") + void leavesNoParamStringUntouched() { + String url = "plc4x:tls://host:59837"; + assertEquals(url, redact(url)); + } + + @Test + @DisplayName("handles null") + void handlesNull() { + assertNull(redact(null)); + } + + @Test + @DisplayName("handles a driver with no configuration class") + void handlesNoConfigurationClass() { + assertEquals("plc4x://host?password=******", + ConnectionStringRedactor.redact("plc4x://host?password=hunter2", null, null, null)); + } +} diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTest.java index f76bd4c7f19..6625c1b48bd 100644 --- a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTest.java +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseTest.java @@ -27,99 +27,7 @@ public class DriverBaseTest { - @Nested - @DisplayName("redactSecrets") - class RedactSecrets { - - @Test - @DisplayName("masks the password parameter value") - void masksPassword() { - assertEquals( - "plc4x:tls://host:59837?remote-connection-string=s7&username=op&password=***&tls.verify-ssl=false", - DriverBase.redactSecrets( - "plc4x:tls://host:59837?remote-connection-string=s7&username=op&password=hunter2&tls.verify-ssl=false")); - } - - @Test - @DisplayName("masks a password that is the first query parameter") - void masksLeadingPassword() { - assertEquals( - "plc4x://host?password=***&username=op", - DriverBase.redactSecrets("plc4x://host?password=hunter2&username=op")); - } - - @Test - @DisplayName("masks transport-prefixed and multiple secret parameters") - void masksMultipleSecrets() { - assertEquals( - "plc4x:tls://host?password=***&tls.keystore-password=***&api-token=***", - DriverBase.redactSecrets( - "plc4x:tls://host?password=abc&tls.keystore-password=def&api-token=ghi")); - } - - @Test - @DisplayName("is case-insensitive on the parameter name") - void caseInsensitive() { - assertEquals( - "plc4x://host?PassWord=***&Secret=***", - DriverBase.redactSecrets("plc4x://host?PassWord=abc&Secret=xyz")); - } - - @Test - @DisplayName("leaves non-secret parameters untouched") - void leavesNonSecretsUntouched() { - String url = "plc4x:tls://host:59837?remote-connection-string=s7&username=op&tls.verify-ssl=false"; - assertEquals(url, DriverBase.redactSecrets(url)); - } - - @Test - @DisplayName("masks the pre-shared key") - void masksPreSharedKey() { - // The key itself is the credential for a TLS-PSK connection, and nothing in the - // pattern reached it: it carries none of password, passwd, secret or token. - assertEquals( - "plc4x:tls-psk://host?tls-psk.psk-identity=plc4x&tls-psk.psk-key=***", - DriverBase.redactSecrets( - "plc4x:tls-psk://host?tls-psk.psk-identity=plc4x&tls-psk.psk-key=0011deadbeef")); - } - - @Test - @DisplayName("masks a passphrase") - void masksPassphrase() { - assertEquals("plc4x://host?passphrase=***", - DriverBase.redactSecrets("plc4x://host?passphrase=open sesame")); - } - - @Test - @DisplayName("leaves the things named like keys that are not keys") - void leavesKeyShapedNonSecretsUntouched() { - // Masking these would cost an operator the diagnosis and protect nothing: a path, a - // store type, a key size, a boolean, and the identity that says *which* key failed. - String url = "plc4x:tls://host?key-store-file=/etc/plc4x/client.p12" - + "&key-store-type=pkcs12&generated-key-size=2048&log-session-keys=false" - + "&tls-psk.psk-identity=plc4x&allow-insecure-credentials=false"; - assertEquals(url, DriverBase.redactSecrets(url)); - } - - @Test - @DisplayName("leaves a connection string without parameters untouched") - void leavesNoParamStringUntouched() { - String url = "plc4x:tls://host:59837"; - assertEquals(url, DriverBase.redactSecrets(url)); - } - - @Test - @DisplayName("does not match parameter names that merely look similar (e.g. username)") - void doesNotMaskUsername() { - assertEquals( - "plc4x://host?username=secretive-bob", - DriverBase.redactSecrets("plc4x://host?username=secretive-bob")); - } - - @Test - @DisplayName("handles null") - void handlesNull() { - assertNull(DriverBase.redactSecrets(null)); - } - } + // The redaction cases moved to ConnectionStringRedactorTest: what is redacted is now decided + // by the @Secret markings on the configuration classes rather than by a pattern living here, + // so the tests need configuration fixtures that this class has no reason to carry. } diff --git a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java index 684e0a76baf..60fb23ddd84 100644 --- a/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java +++ b/plc4j/spi/drivers/src/test/java/org/apache/plc4x/java/spi/drivers/DriverBaseUnknownParameterTest.java @@ -46,7 +46,7 @@ private static List unknownIn(String paramString) { @Test void acceptsParametersTheDriverDeclares() { - assertEquals(List.of(), unknownIn("controller-type=S7_300&read-timeout=5000")); + assertEquals(List.of(), unknownIn("controller-type=S7_300&read-timeout-ms=5000")); } @Test @@ -125,7 +125,7 @@ void keepsTheValueOutOfTheReportedName() { } private static final Set KNOWN = Set.of( - "controller-type", "read-timeout", "allow-unsupported-transport", + "controller-type", "read-timeout-ms", "allow-unsupported-transport", "cotp.remote-slot", "cotp.remote-rack", "cotp.local-tsap", "log.audit-log-file"); /** @@ -174,7 +174,7 @@ public static class DriverConfig implements Configuration { @ConfigurationParameter("controller-type") protected String controllerType = "ANY"; - @ConfigurationParameter("read-timeout") + @ConfigurationParameter("read-timeout-ms") protected int readTimeout = 10000; @ComplexConfigurationParameter(prefix = "encoding", defaultOverrides = {}, requiredOverrides = {}) diff --git a/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/TagBatch.java b/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/TagBatch.java index 7ccd78673d2..2ff31ca320a 100644 --- a/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/TagBatch.java +++ b/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/TagBatch.java @@ -93,7 +93,7 @@ public class TagBatch implements AutoCloseable { // Upper bound on a single fetch cycle. This is NOT a replacement for the driver's // own request timeout (configure that in the connection string, e.g. - // "?request-timeout=10000") — it is deliberately set an order of magnitude higher, + // "?request-timeout-ms=10000") — it is deliberately set an order of magnitude higher, // and exists only so that a driver which never completes its read future cannot // wedge this batch forever: fetchInProgress would stay true and every later trigger // would be skipped, silently killing the batch with no recovery. @@ -228,7 +228,7 @@ public static class Builder { * This is a watchdog, not a request timeout: it only exists so a driver that never * completes its read future cannot wedge the batch permanently. Configure the actual * request timeout on the connection string instead (for example - * {@code "opcua:tcp://host:4840?request-timeout=10000"}), and leave this comfortably + * {@code "opcua:tcp://host:4840?request-timeout-ms=10000"}), and leave this comfortably * above it — the default is 5 minutes. * * @param timeout The timeout, or a value <= 0 to disable the watchdog entirely diff --git a/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/config/BatchConfiguration.java b/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/config/BatchConfiguration.java index c0973247b2d..56529095e9a 100644 --- a/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/config/BatchConfiguration.java +++ b/plc4j/tools/event-pump/src/main/java/org/apache/plc4x/java/tools/eventpump/config/BatchConfiguration.java @@ -71,7 +71,7 @@ public class BatchConfiguration { /** * Watchdog bound for a single fetch cycle, in milliseconds. Null means "use the * default". This is not the request timeout — set that on the connection URL, e.g. - * {@code "opcua:tcp://host:4840?request-timeout=10000"}. + * {@code "opcua:tcp://host:4840?request-timeout-ms=10000"}. */ @JsonProperty("fetchTimeoutMs") private Long fetchTimeoutMs; diff --git a/plc4j/transports/can-socketcan/src/main/java/org/apache/plc4x/java/transport/can/socketcan/config/SocketCanTransportConfiguration.java b/plc4j/transports/can-socketcan/src/main/java/org/apache/plc4x/java/transport/can/socketcan/config/SocketCanTransportConfiguration.java index fde8b24509b..4c2e492dfeb 100644 --- a/plc4j/transports/can-socketcan/src/main/java/org/apache/plc4x/java/transport/can/socketcan/config/SocketCanTransportConfiguration.java +++ b/plc4j/transports/can-socketcan/src/main/java/org/apache/plc4x/java/transport/can/socketcan/config/SocketCanTransportConfiguration.java @@ -56,7 +56,7 @@ public class SocketCanTransportConfiguration extends CanTransportConfiguration { /** * Read timeout in milliseconds for blocking reads on the CAN socket. */ - @ConfigurationParameter("read-timeout") + @ConfigurationParameter("read-timeout-ms") @Description("Read timeout in milliseconds for blocking reads on the CAN socket") @IntDefaultValue(1000) public int readTimeout = 1000; diff --git a/plc4j/transports/cotp/src/main/java/org/apache/plc4x/java/transport/cotp/config/CotpTransportConfiguration.java b/plc4j/transports/cotp/src/main/java/org/apache/plc4x/java/transport/cotp/config/CotpTransportConfiguration.java index 38acab00286..81ae27ecc2b 100644 --- a/plc4j/transports/cotp/src/main/java/org/apache/plc4x/java/transport/cotp/config/CotpTransportConfiguration.java +++ b/plc4j/transports/cotp/src/main/java/org/apache/plc4x/java/transport/cotp/config/CotpTransportConfiguration.java @@ -67,7 +67,7 @@ public class CotpTransportConfiguration extends TcpTransportConfiguration implem * Valid values: 128, 256, 512, 1024, 2048, 4096, 8192. * Default is 8192 bytes. */ - @ConfigurationParameter("cotp-tpdu-size") + @ConfigurationParameter("tpdu-size") @Description("COTP PDU size for data transmission. Valid values: 128, 256, 512, 1024, 2048, 4096, 8192.") @IntDefaultValue(8192) public int cotpTpduSize = 8192; @@ -76,7 +76,7 @@ public class CotpTransportConfiguration extends TcpTransportConfiguration implem * Connection timeout for COTP handshake in milliseconds. * Default is 5000ms (5 seconds). */ - @ConfigurationParameter("cotp-connection-timeout") + @ConfigurationParameter("handshake-timeout-ms") @Description("Connection timeout for COTP handshake in milliseconds.") @IntDefaultValue(5000) public int cotpConnectionTimeout = 5000; diff --git a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java index cb9d78f30fa..34273fa6bf5 100644 --- a/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java +++ b/plc4j/transports/pcap-replay/src/main/java/org/apache/plc4x/java/transport/pcapreplay/config/PcapReplayTransportConfiguration.java @@ -107,7 +107,7 @@ public class PcapReplayTransportConfiguration implements TransportConfiguration /** * Read timeout for blocking reads in milliseconds. 0 means no timeout. */ - @ConfigurationParameter("read-timeout") + @ConfigurationParameter("read-timeout-ms") @Description("Read timeout for blocking reads in milliseconds.") @IntDefaultValue(0) public int readTimeout; diff --git a/plc4j/transports/raw-socket/src/main/java/org/apache/plc4x/java/transport/rawsocket/config/RawSocketTransportConfiguration.java b/plc4j/transports/raw-socket/src/main/java/org/apache/plc4x/java/transport/rawsocket/config/RawSocketTransportConfiguration.java index db0d8fcc046..9ce3485b285 100644 --- a/plc4j/transports/raw-socket/src/main/java/org/apache/plc4x/java/transport/rawsocket/config/RawSocketTransportConfiguration.java +++ b/plc4j/transports/raw-socket/src/main/java/org/apache/plc4x/java/transport/rawsocket/config/RawSocketTransportConfiguration.java @@ -148,7 +148,7 @@ public class RawSocketTransportConfiguration implements TransportConfiguration { /** * Read timeout for blocking reads in milliseconds. 0 means no timeout. */ - @ConfigurationParameter( "read-timeout") + @ConfigurationParameter( "read-timeout-ms") @Description( "Read timeout for blocking reads in milliseconds.") @IntDefaultValue(0) public int readTimeout; diff --git a/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/config/SerialTransportConfiguration.java b/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/config/SerialTransportConfiguration.java index 8b24435364b..00592359f55 100644 --- a/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/config/SerialTransportConfiguration.java +++ b/plc4j/transports/serial/src/main/java/org/apache/plc4x/java/transport/serial/config/SerialTransportConfiguration.java @@ -70,7 +70,7 @@ public class SerialTransportConfiguration implements TransportConfiguration { /** * Read timeout in milliseconds. 0 means blocking read. */ - @ConfigurationParameter( "read-timeout") + @ConfigurationParameter( "read-timeout-ms") @Description( "Read timeout in milliseconds. 0 means blocking read.") @IntDefaultValue(1000) public int readTimeout; @@ -78,7 +78,7 @@ public class SerialTransportConfiguration implements TransportConfiguration { /** * Write timeout in milliseconds. */ - @ConfigurationParameter( "write-timeout") + @ConfigurationParameter( "write-timeout-ms") @Description( "Write timeout in milliseconds.") @IntDefaultValue(1000) public int writeTimeout; diff --git a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/config/TcpTransportConfiguration.java b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/config/TcpTransportConfiguration.java index f0d4287424b..9684dff2531 100644 --- a/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/config/TcpTransportConfiguration.java +++ b/plc4j/transports/tcp/src/main/java/org/apache/plc4x/java/transport/tcp/config/TcpTransportConfiguration.java @@ -30,28 +30,28 @@ public class TcpTransportConfiguration implements TransportConfiguration { /** * Connection timeout in milliseconds. */ - @ConfigurationParameter("connect-timeout") + @ConfigurationParameter("connect-timeout-ms") @IntDefaultValue(5000) public int connectTimeout; /** * Socket read timeout in milliseconds. 0 means no timeout. */ - @ConfigurationParameter("read-timeout") + @ConfigurationParameter("read-timeout-ms") @IntDefaultValue(0) public int readTimeout; /** * Socket write timeout in milliseconds. 0 means no timeout. */ - @ConfigurationParameter("write-timeout") + @ConfigurationParameter("write-timeout-ms") @IntDefaultValue(0) public int writeTimeout; /** * Enable TCP_NODELAY (disable Nagle's algorithm). */ - @ConfigurationParameter("tcp-no-delay") + @ConfigurationParameter("no-delay") @BooleanDefaultValue(true) public boolean tcpNoDelay; diff --git a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/PskTlsTransportInstance.java b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/PskTlsTransportInstance.java index e9837b30585..19792976caa 100644 --- a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/PskTlsTransportInstance.java +++ b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/PskTlsTransportInstance.java @@ -76,7 +76,7 @@ public PskTlsTransportInstance(InetSocketAddress remoteAddress, PskTlsTransportC this.ringBuffer = new RingBuffer(configuration.receiveBufferSize); auditLog.write(AuditLogEventType.SYSTEM, String.format( - "TLS-PSK configuration: target=%s:%d, psk-identity=%s, connect-timeout=%d, read-timeout=%d", + "TLS-PSK configuration: target=%s:%d, psk-identity=%s, connect-timeout-ms=%d, read-timeout-ms=%d", remoteAddress.getHostName(), remoteAddress.getPort(), configuration.pskIdentity, configuration.connectTimeout, configuration.readTimeout)); diff --git a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java index aeaae8ab241..e9384777285 100644 --- a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java +++ b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransport.java @@ -88,7 +88,7 @@ public TransportInstance createTransportInstance(Stri InetSocketAddress remoteAddress = new InetSocketAddress((ip != null) ? ip : hostname, port); - LOGGER.debug("Creating TLS transport instance for {}:{} (verify-ssl={})", + LOGGER.debug("Creating TLS transport instance for {}:{} (verify={})", (ip != null) ? ip : hostname, port, tlsTransportConfiguration.isVerifySsl()); TlsTransportInstance instance = new TlsTransportInstance(remoteAddress, tlsTransportConfiguration, auditLog); diff --git a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransportInstance.java b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransportInstance.java index e755fb58e64..61c50e22071 100644 --- a/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransportInstance.java +++ b/plc4j/transports/tls/src/main/java/org/apache/plc4x/java/transport/tls/TlsTransportInstance.java @@ -80,11 +80,11 @@ public class TlsTransportInstance extends BaseTransportInstanceThis is what makes verification usable for a device carrying its own certificate: without * it the only way past a private CA is to turn verification off entirely, which is why - * {@code verify-ssl=false} tends to end up in configurations and stay there.

+ * {@code verify=false} tends to end up in configurations and stay there.

*/ - @ConfigurationParameter("trust-store-file") + @ConfigurationParameter("trust-store") @Description("Key store of certificates to trust, instead of the JVM's public authorities") public String trustStoreFile; + @Secret @ConfigurationParameter("trust-store-password") - @Description("Password of the trust store named by trust-store-file") + @Description("Password of the trust store named by tls.trust-store") public String trustStorePassword; @ConfigurationParameter("trust-store-type") @StringDefaultValue("PKCS12") - @Description("Type of the trust store named by trust-store-file") + @Description("Type of the trust store named by tls.trust-store") public String trustStoreType; /** @@ -78,7 +80,7 @@ public class TlsTransportConfiguration extends TcpTransportConfiguration { * Valid values: "TLSv1.2", "TLSv1.3" * Some protocols (like Secure ADS) require a specific TLS version. */ - @ConfigurationParameter("tls-version") + @ConfigurationParameter("version") @Description("TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2.") public String tlsVersion; @@ -113,6 +115,7 @@ public String getTlsVersion() { /** * Password for the keystore specified by the keystore parameter. */ + @Secret @ConfigurationParameter("keystore-password") @Description("Password for the client keystore.") public String keystorePassword; diff --git a/plc4j/transports/tls/src/test/java/org/apache/plc4x/java/transport/tls/TlsTransportInstanceTest.java b/plc4j/transports/tls/src/test/java/org/apache/plc4x/java/transport/tls/TlsTransportInstanceTest.java index e302db0e7e6..dcf6ea20c15 100644 --- a/plc4j/transports/tls/src/test/java/org/apache/plc4x/java/transport/tls/TlsTransportInstanceTest.java +++ b/plc4j/transports/tls/src/test/java/org/apache/plc4x/java/transport/tls/TlsTransportInstanceTest.java @@ -713,7 +713,7 @@ void testConnectionErrorMessageContainsHost() { @Test void testCertificateVerificationFailure() { - // Connect with verify-ssl=true to our self-signed server — should fail with PKIX error + // Connect with verify=true to our self-signed server — should fail with PKIX error Future serverFuture = acceptConnection(); TlsTransportConfiguration config = createConfig(); diff --git a/plc4j/transports/udp/src/main/java/org/apache/plc4x/java/transport/udp/config/UdpTransportConfiguration.java b/plc4j/transports/udp/src/main/java/org/apache/plc4x/java/transport/udp/config/UdpTransportConfiguration.java index 69b553c863e..62797b61404 100644 --- a/plc4j/transports/udp/src/main/java/org/apache/plc4x/java/transport/udp/config/UdpTransportConfiguration.java +++ b/plc4j/transports/udp/src/main/java/org/apache/plc4x/java/transport/udp/config/UdpTransportConfiguration.java @@ -47,7 +47,7 @@ public class UdpTransportConfiguration implements TransportConfiguration { /** * Socket read timeout in milliseconds. 0 means no timeout. */ - @ConfigurationParameter( "read-timeout") + @ConfigurationParameter( "read-timeout-ms") @Description( "Socket read timeout in milliseconds. 0 means no timeout.") @IntDefaultValue(0) public int readTimeout; diff --git a/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverNMTIT.xml b/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverNMTIT.xml index 716dbc17716..8eeaad84959 100644 --- a/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverNMTIT.xml +++ b/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverNMTIT.xml @@ -33,7 +33,7 @@ 15 - request-timeout + request-timeout-ms 5000 diff --git a/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverSDOIT.xml b/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverSDOIT.xml index 89a19be36bf..fe3285e9721 100644 --- a/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverSDOIT.xml +++ b/protocols/canopen/src/test/resources/protocols/canopen/CANOpenDriverSDOIT.xml @@ -33,7 +33,7 @@ 15 - request-timeout + request-timeout-ms 5000 diff --git a/protocols/modbus/src/test/resources/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml b/protocols/modbus/src/test/resources/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml index 1a1ce259898..4aeab2c3ddd 100644 --- a/protocols/modbus/src/test/resources/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml +++ b/protocols/modbus/src/test/resources/protocols/modbus/tcp/ManualFactoryModbusTCPDriverTest-testsuite.xml @@ -32,7 +32,7 @@ 1 - request-timeout + request-timeout-ms 5000 diff --git a/website/asciidoc/modules/users/pages/getting-started/plc4go.adoc b/website/asciidoc/modules/users/pages/getting-started/plc4go.adoc index 9e7fe6d0188..d045c135481 100644 --- a/website/asciidoc/modules/users/pages/getting-started/plc4go.adoc +++ b/website/asciidoc/modules/users/pages/getting-started/plc4go.adoc @@ -106,7 +106,7 @@ Now that the `PlcDriverManager` is configured, we can use it to get a new connec ---- // Get a connection to a remote PLC - connectionRequestChanel := driverManager.GetConnection("modbus-tcp://192.168.23.30?unit-identifier=1") + connectionRequestChanel := driverManager.GetConnection("modbus-tcp://192.168.23.30?default-unit-identifier=1") // Wait for the driver to connect (or not) connectionResult := <-connectionRequestChanel diff --git a/website/asciidoc/modules/users/pages/protocols/ctrlx.adoc b/website/asciidoc/modules/users/pages/protocols/ctrlx.adoc index bdb31cd7992..0621a74fc6d 100644 --- a/website/asciidoc/modules/users/pages/protocols/ctrlx.adoc +++ b/website/asciidoc/modules/users/pages/protocols/ctrlx.adoc @@ -84,20 +84,20 @@ PlcConnection connection = new DefaultPlcDriverManager() |=== |Name |Type |Default |Description -|`trust-store-file` +|`tls.trust-store` |STRING | |Key store of certificates to trust, instead of the JVM's public authorities. -|`trust-store-password` +|`tls.trust-store-password` |STRING | -|Password of the trust store named by `trust-store-file`. +|Password of the trust store named by `tls.trust-store`. -|`trust-store-type` +|`tls.trust-store-type` |STRING |`PKCS12` -|Type of the trust store named by `trust-store-file`. +|Type of the trust store named by `tls.trust-store`. |`server-certificate-file` |STRING diff --git a/website/asciidoc/modules/users/pages/protocols/opcua.adoc b/website/asciidoc/modules/users/pages/protocols/opcua.adoc index 527a8384d7e..20b0573accf 100644 --- a/website/asciidoc/modules/users/pages/protocols/opcua.adoc +++ b/website/asciidoc/modules/users/pages/protocols/opcua.adoc @@ -81,20 +81,20 @@ The OPC UA specification defines its own procedures for certificate validation. The driver verifies the server certificate by default and fails closed: if no trust anchor is configured, the server certificate is rejected and the connection fails. Configure one of: -* `trust-store-file` - validate the certificate chain against the given trust store. The acceptance +* `tls.trust-store` - validate the certificate chain against the given trust store. The acceptance relies on regular TLS checks (expiry date, certificate path etc.); it does not validate OPC UA specific parts such as the application URI. * `server-certificate-file` - pin trust to exactly that certificate. Only the certificate read from the configured file is trusted; a certificate learned over the unauthenticated discovery channel is never used as a trust anchor. -WARNING: `insecure-certificate-verification=true` disables server certificate verification +WARNING: `tls.verify=false` disables server certificate verification altogether. This makes the connection vulnerable to man-in-the-middle attacks and is intended for local testing only. === Client certificate -If no `key-store-file` is configured, the driver generates a self-signed application instance +If no `tls.keystore` is configured, the driver generates a self-signed application instance certificate for the session. It is a 2048 bit RSA key signed with SHA-256; use `generated-key-size` to ask for a larger key when the server demands one: @@ -104,7 +104,7 @@ opcua:tcp://127.0.0.1:12686?security-policy=Basic256Sha256&message-security=SIGN A generated certificate is fresh for every connection, so a server that keeps a trust list will reject it until it is trusted there. For anything beyond a first connection attempt, supply your own -certificate through `key-store-file` instead - see +certificate through `tls.keystore` instead - see link:../getting-started/opcua-client-certificate.html[the client certificate tutorial]. == User authentication @@ -160,7 +160,7 @@ The certificate has to use an RSA key, and the server has to trust it - servers separate trust list for user certificates, distinct from the one for application instance certificates. -NOTE: This is not the same certificate as the one configured through `key-store-file`. That one is +NOTE: This is not the same certificate as the one configured through `tls.keystore`. That one is the application instance certificate: it secures the channel and says which installation is talking, whereas the user certificate says who is talking. Nothing stops you from using the same certificate for both, provided the server trusts it for both purposes. diff --git a/website/asciidoc/modules/users/pages/tools/event-pump.adoc b/website/asciidoc/modules/users/pages/tools/event-pump.adoc index 50ea7d22c46..49b41939b63 100644 --- a/website/asciidoc/modules/users/pages/tools/event-pump.adoc +++ b/website/asciidoc/modules/users/pages/tools/event-pump.adoc @@ -71,7 +71,7 @@ PlcConnectionFactory connectionFactory = PlcDriverManager.getDefault().getConnec TagBatch batch = TagBatch.builder() .withBatchId("boiler") .withConnectionFactory(connectionFactory) - .withConnectionString("opcua:tcp://192.168.1.1:4840?request-timeout=10000") + .withConnectionString("opcua:tcp://192.168.1.1:4840?request-timeout-ms=10000") .addTagAddress("temperature", "ns=2;i=1001") .addTagAddress("pressure", "ns=2;i=1002") .withTrigger(new TimerTrigger(5, TimeUnit.SECONDS)) @@ -128,7 +128,7 @@ Rather than assembling batches in code, a whole pump can be described in a YAML, ---- connections: - id: plc1 - url: "opcua:tcp://192.168.1.1:4840?request-timeout=10000" + url: "opcua:tcp://192.168.1.1:4840?request-timeout-ms=10000" batches: - id: boiler @@ -213,7 +213,7 @@ The Event-Pump does not impose a request timeout of its own. How long a read may take is the driver's decision, configured as a parameter on the connection URL, for example: ---- -opcua:tcp://192.168.1.1:4840?request-timeout=10000 +opcua:tcp://192.168.1.1:4840?request-timeout-ms=10000 ---- Consult the documentation of the driver you are using for the parameter it supports. @@ -272,7 +272,7 @@ The concepts map over fairly directly: | `TimerTrigger` interval | `futureTimeOut` constructor argument -| the driver's own `request-timeout` connection string parameter +| the driver's own `request-timeout-ms` connection string parameter |=== The last row is the one to pay attention to. diff --git a/website/asciidoc/modules/users/pages/tools/plc4x-server.adoc b/website/asciidoc/modules/users/pages/tools/plc4x-server.adoc index d9414f98380..bd57a894dfb 100644 --- a/website/asciidoc/modules/users/pages/tools/plc4x-server.adoc +++ b/website/asciidoc/modules/users/pages/tools/plc4x-server.adoc @@ -138,13 +138,13 @@ A client uses the `plc4x` driver. The connection string points at the server, an === Connection string format ---- -plc4x:://:?remote-connection-string=&username=&password=[&tls.verify-ssl=false] +plc4x:://:?remote-connection-string=&username=&password=[&tls.verify=false] ---- * `` is `tls` (default) or `tcp` (plaintext). `plc4x://…` without a prefix uses the default, TLS. * `remote-connection-string` is the *URL-encoded* connection string the server should open to the actual PLC. * `username` / `password` are mandatory. -* `tls.verify-ssl=false` disables certificate validation — needed when the server uses an auto-generated self-signed certificate. With a properly trusted (CA-signed) certificate, leave it at its default (`true`). +* `tls.verify=false` disables certificate validation — needed when the server uses an auto-generated self-signed certificate. With a properly trusted (CA-signed) certificate, leave it at its default (`true`). === Example (Java) @@ -156,7 +156,7 @@ String url = "plc4x:tls://server.example.com:59837" + "?remote-connection-string=s7%3A%2F%2F10.10.1.5" + "&username=operator" + "&password=s3cr3t!" - + "&tls.verify-ssl=false"; // self-signed server cert + + "&tls.verify=false"; // self-signed server cert try (PlcConnection connection = new DefaultPlcDriverManager().getConnection(url)) { PlcReadRequest request = connection.readRequestBuilder() @@ -183,7 +183,7 @@ String url = "plc4x:tcp://server.example.com:59837" | Symptom | Cause / fix | `Server certificate not trusted … PKIX path building failed` -| TLS cert not trusted by the client. Add `tls.verify-ssl=false`, or trust/pin the server cert. +| TLS cert not trusted by the client. Add `tls.verify=false`, or trust/pin the server cert. | Connect fails with `ACCESS_DENIED` / authentication error | Missing or wrong `username` / `password`. diff --git a/website/asciidoc/modules/users/partials/ab-eth.adoc b/website/asciidoc/modules/users/partials/ab-eth.adoc index e5c753ef1fd..f5bedabe67f 100644 --- a/website/asciidoc/modules/users/partials/ab-eth.adoc +++ b/website/asciidoc/modules/users/partials/ab-eth.adoc @@ -37,16 +37,16 @@ - `tcp` 5+|Config options: |`station` |INT |0| |Id of the station we want to connect to. -|`request-timeout` |INT |10000| |Maximum time (in milliseconds) to wait for the gateway to acknowledge the connection request or for a read response. +|`request-timeout-ms` |INT |10000| |Maximum time (in milliseconds) to wait for the gateway to acknowledge the connection request or for a read response. 5+|Transport config options: 5+| +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/ads.adoc b/website/asciidoc/modules/users/partials/ads.adoc index 9af20bcb5af..4cc812da87f 100644 --- a/website/asciidoc/modules/users/partials/ads.adoc +++ b/website/asciidoc/modules/users/partials/ads.adoc @@ -40,7 +40,7 @@ |`target-ams-port` |INT | |required |AMS port of the target. |`source-ams-net-id` |STRUCT | |required |AMS-Net-Id of the source. An AMS-Net-Id has the regular format of an IPv4 IP-Address, however with 6 segments instead of 4. |`source-ams-port` |INT | |required |AMS port of the source. -|`timeout-request` |INT |4000| |Default timeout for all types of requests. +|`request-timeout-ms` |INT |4000| |Default timeout for all types of requests. |`max-data-type-table-depth` |INT |20| |Maximum nesting depth accepted when parsing the data-type table uploaded from the device. An entry may contain further entries, so without a limit the depth of the tree is dictated by the device rather than by the driver, and a table of well under a megabyte can nest deeply enough to exhaust the parser's stack. Real type hierarchies are only a handful of levels deep, so the default is already generous; raise it for a device that is known to need more. Note that the JVM's own stack imposes a practical ceiling of a few thousand levels regardless of what is configured here. |`load-symbol-and-data-type-tables` |BOOLEAN |true| |Configures, if when connecting the data-type- and symbol-table should be read. This is an optimization that can help in cases, where the PLC program is pretty large and downloading the full tables is causing problems. When disabled, reading and writing is limited to direct addresses (`{IndexGroup}/{IndexOffset}:{TYPE}`): symbolic addresses cannot be resolved without the tables and are rejected with a corresponding error. Browsing is unavailable for the same reason. Subscriptions are unaffected, as they resolve symbol handles on the device. 5+|Transport config options: @@ -48,10 +48,10 @@ +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/bacnet-ip.adoc b/website/asciidoc/modules/users/partials/bacnet-ip.adoc index 3ac78900174..3790accf0cc 100644 --- a/website/asciidoc/modules/users/partials/bacnet-ip.adoc +++ b/website/asciidoc/modules/users/partials/bacnet-ip.adoc @@ -45,7 +45,7 @@ +++ |`udp.local-address` |STRING | | |Local address to bind to. If not set, binds to all interfaces. |`udp.local-port` |INT |0| |Local port to bind to. 0 uses ephemeral port. -|`udp.read-timeout` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. +|`udp.read-timeout-ms` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. |`udp.max-packet-size` |INT |65507| |Maximum UDP packet size in bytes. |`udp.send-buffer-size` |INT |0| |Send buffer size in bytes. 0 uses system default. |`udp.receive-buffer-size` |INT |0| |Receive buffer size in bytes. 0 uses system default. diff --git a/website/asciidoc/modules/users/partials/c-bus.adoc b/website/asciidoc/modules/users/partials/c-bus.adoc index e198fa75832..cadd38bdb54 100644 --- a/website/asciidoc/modules/users/partials/c-bus.adoc +++ b/website/asciidoc/modules/users/partials/c-bus.adoc @@ -42,10 +42,10 @@ +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/canopen.adoc b/website/asciidoc/modules/users/partials/canopen.adoc index 49f219620aa..798cde9b874 100644 --- a/website/asciidoc/modules/users/partials/canopen.adoc +++ b/website/asciidoc/modules/users/partials/canopen.adoc @@ -38,7 +38,7 @@ - `can-virtualcan` 5+|Config options: |`node-id` |INT | | |CAN node identifier. Depending on used CAN version it might be 11 or 29 bit unsigned int. -|`request-timeout` |INT |1000| |Time after which dispatched BUS operation (ie. SDO request) will be marked as failed. +|`request-timeout-ms` |INT |1000| |Time after which dispatched BUS operation (ie. SDO request) will be marked as failed. 5+|Transport config options: 5+| +++ diff --git a/website/asciidoc/modules/users/partials/eip.adoc b/website/asciidoc/modules/users/partials/eip.adoc index c40c3ff4745..ee77d9841db 100644 --- a/website/asciidoc/modules/users/partials/eip.adoc +++ b/website/asciidoc/modules/users/partials/eip.adoc @@ -41,7 +41,7 @@ *Since: 1.0.0* |`force-unconnected-operation` |BOOLEAN |false| |Forces the driver to use unconnected requests. + *Since: 0.13.0* -|`request-timeout` |INT |10000| |Default timeout for all types of requests. +|`request-timeout-ms` |INT |10000| |Default timeout for all types of requests. |`communication-path` |STRING | | |The communication path allows for connection routing across multiple backplanes. It uses a common format found in Logix controllers. + It consists of pairs of values, each pair begins with either 1 (Backplane) or 2 (Ethernet), followed by a slot in the case of a backplane address, or if using Ethernet an ip address. e.g. [1,4,2,192.168.0.1,1,1] - Routes to the 4th slot in the first rack, which is an Ethernet module, it then connects to the address 192.168.0.1, then finds the module in slot 1. 5+|Transport config options: @@ -49,10 +49,10 @@ It consists of pairs of values, each pair begins with either 1 (Backplane) or 2 +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/firmata.adoc b/website/asciidoc/modules/users/partials/firmata.adoc index 61d0d30afed..fb28cb9731e 100644 --- a/website/asciidoc/modules/users/partials/firmata.adoc +++ b/website/asciidoc/modules/users/partials/firmata.adoc @@ -37,7 +37,7 @@ - `serial` - `tcp` 5+|Config options: -|`request-timeout` |INT |10000| |Maximum time (in milliseconds) to wait for the initial firmware-report reply during connection setup. +|`request-timeout-ms` |INT |10000| |Maximum time (in milliseconds) to wait for the initial firmware-report reply during connection setup. 5+|Transport config options: 5+| +++ @@ -48,8 +48,8 @@ |`serial.stop-bits` |INT |1| |Number of stop bits (1 or 2) |`serial.parity` |STRING |none| |Parity: none, odd, even, mark, space (case-insensitive) |`serial.flow-control` |STRING |none| |Flow control: none, rts-cts, xon-xoff (case-insensitive) -|`serial.read-timeout` |INT |1000| |Read timeout in milliseconds. 0 means blocking read. -|`serial.write-timeout` |INT |1000| |Write timeout in milliseconds. +|`serial.read-timeout-ms` |INT |1000| |Read timeout in milliseconds. 0 means blocking read. +|`serial.write-timeout-ms` |INT |1000| |Write timeout in milliseconds. |`serial.dtr` |BOOLEAN |false| |Enable DTR (Data Terminal Ready) signal |`serial.rts` |BOOLEAN |false| |Enable RTS (Request To Send) signal |`serial.reuse-port` |BOOLEAN |false| |Reuse the underlying serial port across multiple transport instances. When true, instances with the same port will share a connection. This is useful for protocols where multiple logical connections share one serial port. Connections sharing a port must target distinct unit ids; Modbus RTU responses carry no transaction ids, so same-unit traffic from multiple connections cannot be told apart. @@ -58,10 +58,10 @@ +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/genericcan.adoc b/website/asciidoc/modules/users/partials/genericcan.adoc index 028b2babab8..34815d2b4ff 100644 --- a/website/asciidoc/modules/users/partials/genericcan.adoc +++ b/website/asciidoc/modules/users/partials/genericcan.adoc @@ -38,7 +38,7 @@ - `can-virtualcan` 5+|Config options: |`node-id` |INT | | |Node id of the target device. -|`request-timeout` |INT |1000| |Default timeout for all types of requests. +|`request-timeout-ms` |INT |1000| |Default timeout for all types of requests. 5+|Transport config options: 5+| +++ diff --git a/website/asciidoc/modules/users/partials/iec-60870-5-104.adoc b/website/asciidoc/modules/users/partials/iec-60870-5-104.adoc index 9d526f9ee1a..b000d849fdb 100644 --- a/website/asciidoc/modules/users/partials/iec-60870-5-104.adoc +++ b/website/asciidoc/modules/users/partials/iec-60870-5-104.adoc @@ -36,16 +36,16 @@ |Supported Transports 4+| - `tcp` 5+|Config options: -|`request-timeout` |INT |4000| |Maximum time (in milliseconds) to wait for the test-frame and start-data-transfer handshake replies during connection setup. +|`request-timeout-ms` |INT |4000| |Maximum time (in milliseconds) to wait for the test-frame and start-data-transfer handshake replies during connection setup. 5+|Transport config options: 5+| +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/knxnet-ip.adoc b/website/asciidoc/modules/users/partials/knxnet-ip.adoc index 95030d1362d..155f714cafe 100644 --- a/website/asciidoc/modules/users/partials/knxnet-ip.adoc +++ b/website/asciidoc/modules/users/partials/knxnet-ip.adoc @@ -50,7 +50,7 @@ The default is 3 levels. If the `knxproj-file-path` this information is provided - 'LINK_LAYER' (default): The client becomes a participant of the KNX bus and gets it's own individual KNX address. + - 'RAW': The client gets unmanaged access to the bus (be careful with this) + - 'BUSMONITOR': The client operates as a busmonitor where he can't actively participate on the bus. Only one 'BUSMONITOR' connection is allowed at the same time on a KNXnet/IP gateway. -|`request-timeout` |INT |10000| |Maximum time (in milliseconds) to wait for a reply during the KNXnet/IP search, connect and tunnelling exchanges. +|`request-timeout-ms` |INT |10000| |Maximum time (in milliseconds) to wait for a reply during the KNXnet/IP search, connect and tunnelling exchanges. 5+|Transport config options: 5+| +++ @@ -58,7 +58,7 @@ The default is 3 levels. If the `knxproj-file-path` this information is provided +++ |`udp.local-address` |STRING | | |Local address to bind to. If not set, binds to all interfaces. |`udp.local-port` |INT |0| |Local port to bind to. 0 uses ephemeral port. -|`udp.read-timeout` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. +|`udp.read-timeout-ms` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. |`udp.max-packet-size` |INT |65507| |Maximum UDP packet size in bytes. |`udp.send-buffer-size` |INT |0| |Send buffer size in bytes. 0 uses system default. |`udp.receive-buffer-size` |INT |0| |Receive buffer size in bytes. 0 uses system default. diff --git a/website/asciidoc/modules/users/partials/logix.adoc b/website/asciidoc/modules/users/partials/logix.adoc index 6f27f44f62d..5d04c54ad74 100644 --- a/website/asciidoc/modules/users/partials/logix.adoc +++ b/website/asciidoc/modules/users/partials/logix.adoc @@ -41,7 +41,7 @@ *Since: 1.0.0* |`force-unconnected-operation` |BOOLEAN |false| |Forces the driver to use unconnected requests. + *Since: 0.13.0* -|`request-timeout` |INT |10000| |Default timeout for all types of requests. +|`request-timeout-ms` |INT |10000| |Default timeout for all types of requests. |`communication-path` |STRING | | |The communication path allows for connection routing across multiple backplanes. It uses a common format found in Logix controllers. + It consists of pairs of values, each pair begins with either 1 (Backplane) or 2 (Ethernet), followed by a slot in the case of a backplane address, or if using Ethernet an ip address. e.g. [1,4,2,192.168.0.1,1,1] - Routes to the 4th slot in the first rack, which is an Ethernet module, it then connects to the address 192.168.0.1, then finds the module in slot 1. 5+|Transport config options: @@ -49,10 +49,10 @@ It consists of pairs of values, each pair begins with either 1 (Backplane) or 2 +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/modbus-ascii.adoc b/website/asciidoc/modules/users/partials/modbus-ascii.adoc index d55c3d9a727..a4de4fd429f 100644 --- a/website/asciidoc/modules/users/partials/modbus-ascii.adoc +++ b/website/asciidoc/modules/users/partials/modbus-ascii.adoc @@ -40,7 +40,7 @@ - `tls-psk` - `udp` 5+|Config options: -|`request-timeout` |INT |5000| |Default timeout for all types of requests. The timeout covers the full time from submission including queueing; queued requests whose remaining budget falls below a small dispatch margin (at most a quarter of the timeout, capped at 50 ms) fail fast instead of being sent. +|`request-timeout-ms` |INT |5000| |Default timeout for all types of requests. The timeout covers the full time from submission including queueing; queued requests whose remaining budget falls below a small dispatch margin (at most a quarter of the timeout, capped at 50 ms) fail fast instead of being sent. |`default-unit-identifier` |INT |1| |Unit-identifier or slave-id that identifies the target PLC (On RS485 multiple Modbus Devices can be listening). Defaults to 1. |`ping-address` |STRING |4x00001:BOOL| |Simple address, that the driver will use to check, if the connection to a given device is active (Defaults to reading holding-register 1). |`default-payload-byte-order` |STRING |BIG_ENDIAN| |Default encoding used for transporting register values (Defaults to BIG_ENDIAN). + @@ -65,8 +65,8 @@ Allowed values are: + |`serial.stop-bits` |INT |1| |Number of stop bits (1 or 2) |`serial.parity` |STRING |none| |Parity: none, odd, even, mark, space (case-insensitive) |`serial.flow-control` |STRING |none| |Flow control: none, rts-cts, xon-xoff (case-insensitive) -|`serial.read-timeout` |INT |1000| |Read timeout in milliseconds. 0 means blocking read. -|`serial.write-timeout` |INT |1000| |Write timeout in milliseconds. +|`serial.read-timeout-ms` |INT |1000| |Read timeout in milliseconds. 0 means blocking read. +|`serial.write-timeout-ms` |INT |1000| |Write timeout in milliseconds. |`serial.dtr` |BOOLEAN |false| |Enable DTR (Data Terminal Ready) signal |`serial.rts` |BOOLEAN |false| |Enable RTS (Request To Send) signal |`serial.reuse-port` |BOOLEAN |false| |Reuse the underlying serial port across multiple transport instances. When true, instances with the same port will share a connection. This is useful for protocols where multiple logical connections share one serial port. Connections sharing a port must target distinct unit ids; Modbus RTU responses carry no transaction ids, so same-unit traffic from multiple connections cannot be told apart. @@ -75,10 +75,10 @@ Allowed values are: + +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | @@ -88,20 +88,20 @@ Allowed values are: + +++

tls

+++ -|`tls.verify-ssl` |BOOLEAN |true| | +|`tls.verify` |BOOLEAN |true| | |`tls.ignore-common-name` |BOOLEAN |false| |Accept a server certificate issued for a different host than the one connected to -|`tls.trust-store-file` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities -|`tls.trust-store-password` |STRING | | |Password of the trust store named by trust-store-file -|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by trust-store-file -|`tls.tls-version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. +|`tls.trust-store` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities +|`tls.trust-store-password` |STRING | | |Password of the trust store named by tls.trust-store +|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by tls.trust-store +|`tls.version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. |`tls.keystore` |STRING | | |Path to keystore (PKCS12/JKS) containing the client certificate and private key for mutual TLS. |`tls.keystore-password` |STRING | | |Password for the client keystore. |`tls.keystore-type` |STRING | | |Keystore type (e.g., 'PKCS12', 'JKS'). Defaults to PKCS12. |`tls.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls.connect-timeout` |INT |5000| | -|`tls.read-timeout` |INT |0| | -|`tls.write-timeout` |INT |0| | -|`tls.tcp-no-delay` |BOOLEAN |true| | +|`tls.connect-timeout-ms` |INT |5000| | +|`tls.read-timeout-ms` |INT |0| | +|`tls.write-timeout-ms` |INT |0| | +|`tls.no-delay` |BOOLEAN |true| | |`tls.keep-alive` |BOOLEAN |false| | |`tls.send-buffer-size` |INT |81920| | |`tls.receive-buffer-size` |INT |81920| | @@ -114,10 +114,10 @@ Allowed values are: + |`tls-psk.psk-identity` |STRING | | |PSK identity string for TLS-PSK authentication. Must be used together with psk-key. |`tls-psk.psk-key` |STRING | | |PSK key as hexadecimal string for TLS-PSK authentication. Must be used together with psk-identity. |`tls-psk.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls-psk.connect-timeout` |INT |5000| | -|`tls-psk.read-timeout` |INT |0| | -|`tls-psk.write-timeout` |INT |0| | -|`tls-psk.tcp-no-delay` |BOOLEAN |true| | +|`tls-psk.connect-timeout-ms` |INT |5000| | +|`tls-psk.read-timeout-ms` |INT |0| | +|`tls-psk.write-timeout-ms` |INT |0| | +|`tls-psk.no-delay` |BOOLEAN |true| | |`tls-psk.keep-alive` |BOOLEAN |false| | |`tls-psk.send-buffer-size` |INT |81920| | |`tls-psk.receive-buffer-size` |INT |81920| | @@ -129,7 +129,7 @@ Allowed values are: + +++ |`udp.local-address` |STRING | | |Local address to bind to. If not set, binds to all interfaces. |`udp.local-port` |INT |0| |Local port to bind to. 0 uses ephemeral port. -|`udp.read-timeout` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. +|`udp.read-timeout-ms` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. |`udp.max-packet-size` |INT |65507| |Maximum UDP packet size in bytes. |`udp.send-buffer-size` |INT |0| |Send buffer size in bytes. 0 uses system default. |`udp.receive-buffer-size` |INT |0| |Receive buffer size in bytes. 0 uses system default. diff --git a/website/asciidoc/modules/users/partials/modbus-rtu.adoc b/website/asciidoc/modules/users/partials/modbus-rtu.adoc index 43f9f20ae4f..8d997c35dcc 100644 --- a/website/asciidoc/modules/users/partials/modbus-rtu.adoc +++ b/website/asciidoc/modules/users/partials/modbus-rtu.adoc @@ -40,7 +40,7 @@ - `tls-psk` - `udp` 5+|Config options: -|`request-timeout` |INT |5000| |Default timeout for all types of requests. The timeout covers the full time from submission including queueing; queued requests whose remaining budget falls below a small dispatch margin (at most a quarter of the timeout, capped at 50 ms) fail fast instead of being sent. +|`request-timeout-ms` |INT |5000| |Default timeout for all types of requests. The timeout covers the full time from submission including queueing; queued requests whose remaining budget falls below a small dispatch margin (at most a quarter of the timeout, capped at 50 ms) fail fast instead of being sent. |`default-unit-identifier` |INT |1| |Unit-identifier or slave-id that identifies the target PLC (On RS485 multiple Modbus Devices can be listening). Defaults to 1. |`ping-address` |STRING |4x00001:BOOL| |Simple address, that the driver will use to check, if the connection to a given device is active (Defaults to reading holding-register 1). |`default-payload-byte-order` |STRING |BIG_ENDIAN| |Default encoding used for transporting register values (Defaults to BIG_ENDIAN). + @@ -65,8 +65,8 @@ Allowed values are: + |`serial.stop-bits` |INT |1| |Number of stop bits (1 or 2) |`serial.parity` |STRING |none| |Parity: none, odd, even, mark, space (case-insensitive) |`serial.flow-control` |STRING |none| |Flow control: none, rts-cts, xon-xoff (case-insensitive) -|`serial.read-timeout` |INT |1000| |Read timeout in milliseconds. 0 means blocking read. -|`serial.write-timeout` |INT |1000| |Write timeout in milliseconds. +|`serial.read-timeout-ms` |INT |1000| |Read timeout in milliseconds. 0 means blocking read. +|`serial.write-timeout-ms` |INT |1000| |Write timeout in milliseconds. |`serial.dtr` |BOOLEAN |false| |Enable DTR (Data Terminal Ready) signal |`serial.rts` |BOOLEAN |false| |Enable RTS (Request To Send) signal |`serial.reuse-port` |BOOLEAN |false| |Reuse the underlying serial port across multiple transport instances. When true, instances with the same port will share a connection. This is useful for protocols where multiple logical connections share one serial port. Connections sharing a port must target distinct unit ids; Modbus RTU responses carry no transaction ids, so same-unit traffic from multiple connections cannot be told apart. @@ -75,10 +75,10 @@ Allowed values are: + +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | @@ -88,20 +88,20 @@ Allowed values are: + +++

tls

+++ -|`tls.verify-ssl` |BOOLEAN |true| | +|`tls.verify` |BOOLEAN |true| | |`tls.ignore-common-name` |BOOLEAN |false| |Accept a server certificate issued for a different host than the one connected to -|`tls.trust-store-file` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities -|`tls.trust-store-password` |STRING | | |Password of the trust store named by trust-store-file -|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by trust-store-file -|`tls.tls-version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. +|`tls.trust-store` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities +|`tls.trust-store-password` |STRING | | |Password of the trust store named by tls.trust-store +|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by tls.trust-store +|`tls.version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. |`tls.keystore` |STRING | | |Path to keystore (PKCS12/JKS) containing the client certificate and private key for mutual TLS. |`tls.keystore-password` |STRING | | |Password for the client keystore. |`tls.keystore-type` |STRING | | |Keystore type (e.g., 'PKCS12', 'JKS'). Defaults to PKCS12. |`tls.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls.connect-timeout` |INT |5000| | -|`tls.read-timeout` |INT |0| | -|`tls.write-timeout` |INT |0| | -|`tls.tcp-no-delay` |BOOLEAN |true| | +|`tls.connect-timeout-ms` |INT |5000| | +|`tls.read-timeout-ms` |INT |0| | +|`tls.write-timeout-ms` |INT |0| | +|`tls.no-delay` |BOOLEAN |true| | |`tls.keep-alive` |BOOLEAN |false| | |`tls.send-buffer-size` |INT |81920| | |`tls.receive-buffer-size` |INT |81920| | @@ -114,10 +114,10 @@ Allowed values are: + |`tls-psk.psk-identity` |STRING | | |PSK identity string for TLS-PSK authentication. Must be used together with psk-key. |`tls-psk.psk-key` |STRING | | |PSK key as hexadecimal string for TLS-PSK authentication. Must be used together with psk-identity. |`tls-psk.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls-psk.connect-timeout` |INT |5000| | -|`tls-psk.read-timeout` |INT |0| | -|`tls-psk.write-timeout` |INT |0| | -|`tls-psk.tcp-no-delay` |BOOLEAN |true| | +|`tls-psk.connect-timeout-ms` |INT |5000| | +|`tls-psk.read-timeout-ms` |INT |0| | +|`tls-psk.write-timeout-ms` |INT |0| | +|`tls-psk.no-delay` |BOOLEAN |true| | |`tls-psk.keep-alive` |BOOLEAN |false| | |`tls-psk.send-buffer-size` |INT |81920| | |`tls-psk.receive-buffer-size` |INT |81920| | @@ -129,7 +129,7 @@ Allowed values are: + +++ |`udp.local-address` |STRING | | |Local address to bind to. If not set, binds to all interfaces. |`udp.local-port` |INT |0| |Local port to bind to. 0 uses ephemeral port. -|`udp.read-timeout` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. +|`udp.read-timeout-ms` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. |`udp.max-packet-size` |INT |65507| |Maximum UDP packet size in bytes. |`udp.send-buffer-size` |INT |0| |Send buffer size in bytes. 0 uses system default. |`udp.receive-buffer-size` |INT |0| |Receive buffer size in bytes. 0 uses system default. diff --git a/website/asciidoc/modules/users/partials/modbus-tcp.adoc b/website/asciidoc/modules/users/partials/modbus-tcp.adoc index 79b206770ad..49712ec7436 100644 --- a/website/asciidoc/modules/users/partials/modbus-tcp.adoc +++ b/website/asciidoc/modules/users/partials/modbus-tcp.adoc @@ -39,7 +39,7 @@ - `tls-psk` - `udp` 5+|Config options: -|`request-timeout` |INT |5000| |Default timeout for all types of requests. +|`request-timeout-ms` |INT |5000| |Default timeout for all types of requests. |`default-unit-identifier` |INT |1| |Unit-identifier or slave-id that identifies the target PLC (On RS485 multiple Modbus Devices can be listening). Defaults to 1. |`ping-address` |STRING |4x00001:BOOL| |Simple address, that the driver will use to check, if the connection to a given device is active (Defaults to reading holding-register 1). |`default-payload-byte-order` |STRING |BIG_ENDIAN| |Default encoding used for transporting register values (Defaults to BIG_ENDIAN). + @@ -59,10 +59,10 @@ Allowed values are: + +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | @@ -72,20 +72,20 @@ Allowed values are: + +++

tls

+++ -|`tls.verify-ssl` |BOOLEAN |true| | +|`tls.verify` |BOOLEAN |true| | |`tls.ignore-common-name` |BOOLEAN |false| |Accept a server certificate issued for a different host than the one connected to -|`tls.trust-store-file` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities -|`tls.trust-store-password` |STRING | | |Password of the trust store named by trust-store-file -|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by trust-store-file -|`tls.tls-version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. +|`tls.trust-store` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities +|`tls.trust-store-password` |STRING | | |Password of the trust store named by tls.trust-store +|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by tls.trust-store +|`tls.version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. |`tls.keystore` |STRING | | |Path to keystore (PKCS12/JKS) containing the client certificate and private key for mutual TLS. |`tls.keystore-password` |STRING | | |Password for the client keystore. |`tls.keystore-type` |STRING | | |Keystore type (e.g., 'PKCS12', 'JKS'). Defaults to PKCS12. |`tls.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls.connect-timeout` |INT |5000| | -|`tls.read-timeout` |INT |0| | -|`tls.write-timeout` |INT |0| | -|`tls.tcp-no-delay` |BOOLEAN |true| | +|`tls.connect-timeout-ms` |INT |5000| | +|`tls.read-timeout-ms` |INT |0| | +|`tls.write-timeout-ms` |INT |0| | +|`tls.no-delay` |BOOLEAN |true| | |`tls.keep-alive` |BOOLEAN |false| | |`tls.send-buffer-size` |INT |81920| | |`tls.receive-buffer-size` |INT |81920| | @@ -98,10 +98,10 @@ Allowed values are: + |`tls-psk.psk-identity` |STRING | | |PSK identity string for TLS-PSK authentication. Must be used together with psk-key. |`tls-psk.psk-key` |STRING | | |PSK key as hexadecimal string for TLS-PSK authentication. Must be used together with psk-identity. |`tls-psk.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls-psk.connect-timeout` |INT |5000| | -|`tls-psk.read-timeout` |INT |0| | -|`tls-psk.write-timeout` |INT |0| | -|`tls-psk.tcp-no-delay` |BOOLEAN |true| | +|`tls-psk.connect-timeout-ms` |INT |5000| | +|`tls-psk.read-timeout-ms` |INT |0| | +|`tls-psk.write-timeout-ms` |INT |0| | +|`tls-psk.no-delay` |BOOLEAN |true| | |`tls-psk.keep-alive` |BOOLEAN |false| | |`tls-psk.send-buffer-size` |INT |81920| | |`tls-psk.receive-buffer-size` |INT |81920| | @@ -113,7 +113,7 @@ Allowed values are: + +++ |`udp.local-address` |STRING | | |Local address to bind to. If not set, binds to all interfaces. |`udp.local-port` |INT |0| |Local port to bind to. 0 uses ephemeral port. -|`udp.read-timeout` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. +|`udp.read-timeout-ms` |INT |0| |Socket read timeout in milliseconds. 0 means no timeout. |`udp.max-packet-size` |INT |65507| |Maximum UDP packet size in bytes. |`udp.send-buffer-size` |INT |0| |Send buffer size in bytes. 0 uses system default. |`udp.receive-buffer-size` |INT |0| |Receive buffer size in bytes. 0 uses system default. diff --git a/website/asciidoc/modules/users/partials/opcua.adoc b/website/asciidoc/modules/users/partials/opcua.adoc index 753ed2cc68d..ddb25feb42b 100644 --- a/website/asciidoc/modules/users/partials/opcua.adoc +++ b/website/asciidoc/modules/users/partials/opcua.adoc @@ -51,21 +51,21 @@ The discovery phase is always conducted using `NONE` security policy. Possible options are `NONE`, `Basic128Rsa15`, `Basic256`, `Basic256Sha256`, `Aes128_Sha256_RsaOaep`, `Aes256_Sha256_RsaPss`. + `NONE` means the channel is neither signed nor encrypted, so anything on the path can read and + change what is exchanged; it also leaves the server unauthenticated. A policy that signs and + -encrypts needs a trust anchor for the server's certificate - see `trust-store-file` and + +encrypts needs a trust anchor for the server's certificate - see `tls.trust-store` and + `server-certificate-file`. |`message-security` |STRING |SIGN_ENCRYPT| |The security policy applied to messages exchanged after handshake phase. + Possible options are `NONE`, `SIGN`, `SIGN_ENCRYPT`. + This option is effective only when `securityPolicy` turns encryption (anything beyond `NONE`). -|`key-store-file` |STRING | | |The Keystore file used to lookup client certificate and its private key. -|`key-store-type` |STRING |pkcs12| |Keystore type used to access keystore and private key, defaults to PKCS (for Java 11+). + +|`tls.keystore` |STRING | | |The Keystore file used to lookup client certificate and its private key. +|`tls.keystore-type` |STRING |pkcs12| |Keystore type used to access keystore and private key, defaults to PKCS (for Java 11+). + Possible values are between others `jks`, `pkcs11`, `dks`, `jceks`. -|`key-store-password` |STRING | | |Java keystore password used to access keystore and private key. -|`generated-key-size` |INT |2048| |Size in bits of the RSA key of the certificate the driver generates when no `key-store-file` is configured. It is ignored when a key store is supplied, as the key then comes from that store. Some servers require a minimum size; 4096 is a common requirement. +|`tls.keystore-password` |STRING | | |Java keystore password used to access keystore and private key. +|`generated-key-size` |INT |2048| |Size in bits of the RSA key of the certificate the driver generates when no `tls.keystore` is configured. It is ignored when a key store is supplied, as the key then comes from that store. Some servers require a minimum size; 4096 is a common requirement. |`server-certificate-file` |STRING | | |Filesystem location where server certificate is located, supported formats are `DER` and `PEM`. -|`trust-store-file` |STRING | | |The trust store file used to verify server certificates and its chain. -|`trust-store-type` |STRING |pkcs12| |Keystore type used to access keystore and private key, defaults to PKCS (for Java 11+). + +|`tls.trust-store` |STRING | | |The trust store file used to verify server certificates and its chain. +|`tls.trust-store-type` |STRING |pkcs12| |Keystore type used to access keystore and private key, defaults to PKCS (for Java 11+). + Possible values are between others `jks`, `pkcs11`, `dks`, `jceks`. -|`trust-store-password` |STRING | | |Password used to open trust store. +|`tls.trust-store-password` |STRING | | |Password used to open trust store. |`allow-insecure-credentials` |BOOLEAN |false| |Allows a username and password to be sent over a channel that neither signs nor encrypts. + Without this, a connection configured with credentials over an unprotected channel fails rather + than putting the password on the wire where anything on the path can read it. Setting it warns. @@ -81,15 +81,15 @@ has not named before. Set to 0 for no limit. |`browse-max-depth` |INT |64| |How deep a browse will recurse into the node tree. Already-visited nodes are never + expanded twice, so a reference cycle terminates on its own, but a server naming a fresh node at + every level describes a tree with no bottom. Set to 0 for no limit. -|`insecure-certificate-verification` |BOOLEAN |false| |Disables verification of the OPC UA server certificate, trusting any certificate the server presents. + +|`tls.verify` |BOOLEAN |true| |Verifies the OPC UA server's certificate. Set to false to trust any certificate the server presents. + This is UNSAFE: it leaves the connection open to man-in-the-middle attacks and defeats the integrity/authenticity + -guarantees of a signed secure channel. Only enable it for local testing. In production, establish trust with + -`trust-store-file` (chain validation) or `server-certificate-file` (certificate pinning) instead. -|`channel-lifetime` |LONG |3600000| |Time for which negotiated secure channel, its keys and session remains open. Value in milliseconds, by default 60 minutes. -|`min-channel-lifetime` |LONG |5000| |Shortest secure-channel lifetime this client will work with, in milliseconds. A server may revise the requested channel-lifetime downwards, and the renewal schedule is derived from whatever it returns - so a very short lifetime means very frequent renewals, on an executor shared by every OPC UA connection in this JVM. A server-supplied lifetime below this value is raised to it and a warning is logged. If a server genuinely needs faster renewal, lower this value to accept it; the default is far below any lifetime a conforming server negotiates. -|`session-timeout` |LONG |120000| |Expiry time for opened secure session, value in milliseconds. Defaults to 2 minutes. -|`negotiation-timeout` |LONG |60000| |Timeout for all negotiation steps prior acceptance of application level operations - this timeout applies to open secure channel, create session and close calls. Defaults to 60 seconds. -|`request-timeout` |LONG |30000| |Timeout for read/write/subscribe calls. Value in milliseconds. +guarantees of a signed secure channel. Only turn verification off for local testing. In production, establish trust with + +`tls.trust-store` (chain validation) or `server-certificate-file` (certificate pinning) instead. +|`channel-lifetime-ms` |LONG |3600000| |Time for which negotiated secure channel, its keys and session remains open. Value in milliseconds, by default 60 minutes. +|`min-channel-lifetime-ms` |LONG |5000| |Shortest secure-channel lifetime this client will work with, in milliseconds. A server may revise the requested channel-lifetime-ms downwards, and the renewal schedule is derived from whatever it returns - so a very short lifetime means very frequent renewals, on an executor shared by every OPC UA connection in this JVM. A server-supplied lifetime below this value is raised to it and a warning is logged. If a server genuinely needs faster renewal, lower this value to accept it; the default is far below any lifetime a conforming server negotiates. +|`session-timeout-ms` |LONG |120000| |Expiry time for opened secure session, value in milliseconds. Defaults to 2 minutes. +|`handshake-timeout-ms` |LONG |60000| |Timeout for all negotiation steps prior acceptance of application level operations - this timeout applies to open secure channel, create session and close calls. Defaults to 60 seconds. +|`request-timeout-ms` |LONG |30000| |Timeout for read/write/subscribe calls. Value in milliseconds. |`endpoint-host` |STRING | | |Endpoint host used to establish secure channel connection. Used when client made connection to server which advertises different hostname than one used for network connection. |`endpoint-port` |INT | | |Endpoint port used to establish secure channel. Used when client made connection to server which advertises different port number than one used for network connection. |`subscription-queue-size` |LONG |1| |Server-side queue depth per monitored item for subscriptions. 1 (default) keeps only + @@ -100,10 +100,10 @@ change-of-state tags, whose sampling rate can exceed the publishing (cycle) inte +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/open-protocol.adoc b/website/asciidoc/modules/users/partials/open-protocol.adoc index 48a620d9851..b9f76e5b201 100644 --- a/website/asciidoc/modules/users/partials/open-protocol.adoc +++ b/website/asciidoc/modules/users/partials/open-protocol.adoc @@ -36,16 +36,16 @@ |Supported Transports 4+| - `tcp` 5+|Config options: -|`request-timeout` |INT |10000| |Maximum time (in milliseconds) to wait for a reply during the Open-Protocol session setup or any per-request exchange. +|`request-timeout-ms` |INT |10000| |Maximum time (in milliseconds) to wait for a reply during the Open-Protocol session setup or any per-request exchange. 5+|Transport config options: 5+| +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/plc4x.adoc b/website/asciidoc/modules/users/partials/plc4x.adoc index a14d1ae1025..eb532aa252a 100644 --- a/website/asciidoc/modules/users/partials/plc4x.adoc +++ b/website/asciidoc/modules/users/partials/plc4x.adoc @@ -38,7 +38,7 @@ - `tcp` 5+|Config options: |`remote-connection-string` |STRING | | |URL-Encoded connection string to use on the proxy side to reach the given PLC. -|`request-timeout` |INT |5000| |Default timeout for all types of requests. +|`request-timeout-ms` |INT |5000| |Default timeout for all types of requests. |`username` |STRING | | |Username for authenticating against the PLC4X proxy server. Authentication is mandatory. |`password` |STRING | | |Password for authenticating against the PLC4X proxy server. Authentication is mandatory. 5+|Transport config options: @@ -46,20 +46,20 @@ +++

tls

+++ -|`tls.verify-ssl` |BOOLEAN |true| | +|`tls.verify` |BOOLEAN |true| | |`tls.ignore-common-name` |BOOLEAN |false| |Accept a server certificate issued for a different host than the one connected to -|`tls.trust-store-file` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities -|`tls.trust-store-password` |STRING | | |Password of the trust store named by trust-store-file -|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by trust-store-file -|`tls.tls-version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. +|`tls.trust-store` |STRING | | |Key store of certificates to trust, instead of the JVM's public authorities +|`tls.trust-store-password` |STRING | | |Password of the trust store named by tls.trust-store +|`tls.trust-store-type` |STRING |PKCS12| |Type of the trust store named by tls.trust-store +|`tls.version` |STRING | | |TLS protocol version (e.g., 'TLSv1.2', 'TLSv1.3'). If not set, uses TLS 1.3 with fallback to TLS 1.2. |`tls.keystore` |STRING | | |Path to keystore (PKCS12/JKS) containing the client certificate and private key for mutual TLS. |`tls.keystore-password` |STRING | | |Password for the client keystore. |`tls.keystore-type` |STRING | | |Keystore type (e.g., 'PKCS12', 'JKS'). Defaults to PKCS12. |`tls.log-session-keys` |BOOLEAN |false| |Log TLS session keys to the audit log in SSLKEYLOGFILE format for Wireshark decryption. -|`tls.connect-timeout` |INT |5000| | -|`tls.read-timeout` |INT |0| | -|`tls.write-timeout` |INT |0| | -|`tls.tcp-no-delay` |BOOLEAN |true| | +|`tls.connect-timeout-ms` |INT |5000| | +|`tls.read-timeout-ms` |INT |0| | +|`tls.write-timeout-ms` |INT |0| | +|`tls.no-delay` |BOOLEAN |true| | |`tls.keep-alive` |BOOLEAN |false| | |`tls.send-buffer-size` |INT |81920| | |`tls.receive-buffer-size` |INT |81920| | @@ -69,10 +69,10 @@ +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/profinet.adoc b/website/asciidoc/modules/users/partials/profinet.adoc index f20d70d8266..b835765acc0 100644 --- a/website/asciidoc/modules/users/partials/profinet.adoc +++ b/website/asciidoc/modules/users/partials/profinet.adoc @@ -58,6 +58,6 @@ |`raw-socket.reuse-interface` |BOOLEAN |false| |Reuse the underlying network interface across multiple transport instances. When true, instances with the same interface and protocol will share a pcap handle. This is useful for protocols where multiple logical connections share one Ethernet type. |`raw-socket.bpf-filter` |STRING | | |BPF (Berkeley Packet Filter) expression to filter packets. |`raw-socket.max-frame-size` |INT |1500| |Maximum frame size (MTU) in bytes. -|`raw-socket.read-timeout` |INT |0| |Read timeout for blocking reads in milliseconds. +|`raw-socket.read-timeout-ms` |INT |0| |Read timeout for blocking reads in milliseconds. |`raw-socket.include-ethernet-header` |BOOLEAN |false| |Deliver full Ethernet frames to the driver and accept raw Ethernet frames on send. Required for L2 protocols that build their own Ethernet headers. |=== diff --git a/website/asciidoc/modules/users/partials/s7.adoc b/website/asciidoc/modules/users/partials/s7.adoc index da227c85ce4..51b44607e90 100644 --- a/website/asciidoc/modules/users/partials/s7.adoc +++ b/website/asciidoc/modules/users/partials/s7.adoc @@ -40,9 +40,9 @@ |`max-amq-caller` |INT |8| |Maximum number of unconfirmed requests the PLC will accept in parallel. |`max-amq-callee` |INT |8| |Maximum number of unconfirmed responses or requests PLC4X will accept in parallel. |`controller-type` |STRING |ANY| |Skip controller-type detection and assume the given type. -|`read-timeout` |INT |10000| |Maximum waiting time (in milliseconds) for a single S7 request/response exchange. -|`ha-heartbeat-interval` |INT |4000| |S7H dual-path only: interval between heartbeat ticks (in milliseconds). Each tick pings each inner connection so a standby disruption is detected within interval + ha-failover-timeout. Lower values detect faster but generate more background traffic. Default 4000 (4s). -|`ha-failover-timeout` |INT |2000| |S7H dual-path only: maximum time (in milliseconds) the wrapper waits for an operation on the active inner before swapping to the alternate. The same value is used as the per-tick ping timeout in the heartbeat. Lower values fail over faster but risk swapping on transient slow responses. Default 2000 (2s). +|`read-timeout-ms` |INT |10000| |Maximum waiting time (in milliseconds) for a single S7 request/response exchange. +|`ha-heartbeat-interval-ms` |INT |4000| |S7H dual-path only: interval between heartbeat ticks (in milliseconds). Each tick pings each inner connection so a standby disruption is detected within interval + ha-failover-timeout-ms. Lower values detect faster but generate more background traffic. Default 4000 (4s). +|`ha-failover-timeout-ms` |INT |2000| |S7H dual-path only: maximum time (in milliseconds) the wrapper waits for an operation on the active inner before swapping to the alternate. The same value is used as the per-tick ping timeout in the heartbeat. Lower values fail over faster but risk swapping on transient slow responses. Default 2000 (2s). 5+|Transport config options: 5+| +++ @@ -56,13 +56,13 @@ |`cotp.remote-device-group` |STRING |PG_OR_PC| |Remote Device Group. |`cotp.local-tsap` |INT |0| |Local TSAP (Transport Service Access Point) identifier. |`cotp.remote-tsap` |INT |0| |Remote TSAP (Transport Service Access Point) identifier. -|`cotp.cotp-tpdu-size` |INT |8192| |COTP PDU size for data transmission. Valid values: 128, 256, 512, 1024, 2048, 4096, 8192. -|`cotp.cotp-connection-timeout` |INT |5000| |Connection timeout for COTP handshake in milliseconds. +|`cotp.tpdu-size` |INT |8192| |COTP PDU size for data transmission. Valid values: 128, 256, 512, 1024, 2048, 4096, 8192. +|`cotp.handshake-timeout-ms` |INT |5000| |Connection timeout for COTP handshake in milliseconds. |`cotp.protocol-class` |INT |0| |COTP protocol class to use. Class 0 is most commonly used (simple class, no flow control). -|`cotp.connect-timeout` |INT |5000| | -|`cotp.read-timeout` |INT |0| | -|`cotp.write-timeout` |INT |0| | -|`cotp.tcp-no-delay` |BOOLEAN |true| | +|`cotp.connect-timeout-ms` |INT |5000| | +|`cotp.read-timeout-ms` |INT |0| | +|`cotp.write-timeout-ms` |INT |0| | +|`cotp.no-delay` |BOOLEAN |true| | |`cotp.keep-alive` |BOOLEAN |false| | |`cotp.send-buffer-size` |INT |81920| | |`cotp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/slmp.adoc b/website/asciidoc/modules/users/partials/slmp.adoc index 1e70cbfb0ca..e04d37152f6 100644 --- a/website/asciidoc/modules/users/partials/slmp.adoc +++ b/website/asciidoc/modules/users/partials/slmp.adoc @@ -37,16 +37,16 @@ - `tcp` 5+|Config options: |`monitoring-timer` |INT |0| |SLMP monitoring timer written into each 3E request frame (0 = wait infinitely). -|`request-timeout` |INT |5000| |Client-side timeout in milliseconds awaiting a response. +|`request-timeout-ms` |INT |5000| |Client-side timeout in milliseconds awaiting a response. 5+|Transport config options: 5+| +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| | diff --git a/website/asciidoc/modules/users/partials/umas.adoc b/website/asciidoc/modules/users/partials/umas.adoc index 922c881d114..0c22b45070f 100644 --- a/website/asciidoc/modules/users/partials/umas.adoc +++ b/website/asciidoc/modules/users/partials/umas.adoc @@ -37,7 +37,7 @@ - `tcp` 5+|Config options: |`unit-identifier` |INT |0| |Modbus unit identifier (slave address). UMAS typically uses 0. -|`request-timeout` |INT |4000| |Timeout in milliseconds for UMAS requests. +|`request-timeout-ms` |INT |4000| |Timeout in milliseconds for UMAS requests. |`max-frame-size` |INT |65535| |Maximum UMAS frame size. The PLC reports its actual limit during InitComms. |`browser-generate-array-nodes` |BOOLEAN |true| |Tells the browser to generate artificial child nodes representing individual array elements. 5+|Transport config options: @@ -45,10 +45,10 @@ +++

tcp

+++ -|`tcp.connect-timeout` |INT |5000| | -|`tcp.read-timeout` |INT |0| | -|`tcp.write-timeout` |INT |0| | -|`tcp.tcp-no-delay` |BOOLEAN |true| | +|`tcp.connect-timeout-ms` |INT |5000| | +|`tcp.read-timeout-ms` |INT |0| | +|`tcp.write-timeout-ms` |INT |0| | +|`tcp.no-delay` |BOOLEAN |true| | |`tcp.keep-alive` |BOOLEAN |false| | |`tcp.send-buffer-size` |INT |81920| | |`tcp.receive-buffer-size` |INT |81920| |