Skip to content
GitHub

The OPC UA server

Added in 4.13.0. internal/opcuaserver publishes this Gateway’s configured devices and tags as an OPC UA address space, so a third-party OPC UA client, a SCADA package, an HMI, a historian, a test tool such as UaExpert, can read the Gateway’s live tags without anyone writing a HotLoop driver for the other side. It is the server half of what internal/driver’s OPC UA client does in the other direction, and it is the increment that makes “a third-party OPC UA client reads Gateway tags” true.

Off by default (HOTLOOP_OPCUA_SERVER_ENABLED): opening a listener on a plant network is something a deployment does on purpose. Default port 4840.

It is read-only. See Why it is read-only.

One Object node per enabled device, directly under the standard Objects folder, and one Variable node per enabled tag beneath its device. Devices and tags that are switched off are not published, they have no worker and no reading, so publishing them would only put a permanently Bad node in front of a client. The address space is exactly what the runtime is polling.

Everything lives in one namespace, urn:hotloop:ua:gateway. The namespace URI is a constant, not derived from the host name or the application URI: a client stores a node as URI plus identifier and finds the namespace index again on every connect, and a URI that changed with a pod name would orphan every subscription a SCADA project has configured. Resolve the index from the server’s namespace array by URI, as a real client does; do not assume ns=2.

Identifiers are strings, and stable:

Node NodeId identifier
Device <deviceId>
Tag <deviceId>/<tagName>
A tag’s EngineeringUnits / EURange property ~EngineeringUnits/<deviceId>/<tagName> and ~EURange/…

The tag form is model.TagKey.String(), the device/tag spelling the rest of the code base already uses, so a NodeId and a tag key convert into each other with model.ParseTagKey. A . separator would not have worked: device ids may contain dots, so a.b.c could be device a tag b.c or device a.b tag c. A device id cannot contain a slash, so the first slash is always the boundary, and a tag name that contains slashes (OPC UA browse paths and MQTT topics do) still parses to the right device. Property ids start with ~, which a device id cannot, so a property can never collide with a tag whatever the tag is called.

Attribute Value
BrowseName the device id / the tag name, in the HotLoop namespace
DisplayName the device’s name / the tag’s name
Description the device’s or tag’s description
DataType see below
ValueRank scalar (-1)
AccessLevel, UserAccessLevel CurrentRead only (0x01)
MinimumSamplingInterval the device’s effective poll interval in ms, values cannot change faster than the runtime polls them, so a client asking for a faster sampling rate is revised up to it
Historizing false

A tag with a unit gets an EngineeringUnits property (EUInformation, unit id -1 = “not a UNECE code”, the unit text in DisplayName), and a tag with a range gets an EURange property. A tag with neither has neither.

The runtime reduces every numeric reading to a float64 in engineering units, so the declared type is a presentation choice made in kindForTag, and it has to be one the value can always be expressed in:

Tag dataType OPC UA type
bool, boolean, bit, coil, discrete Boolean
string, text, char String
int16 Int16
uint16, word UInt16
int32, dint Int32
uint32, dword UInt32
uint64 UInt64
int, int64 Int64 (the widest signed type, so nothing overflows)
anything else, including float Double
any integer type with a scale or an offset Double

The last row matters: an int16 register scaled by 0.1 reads 23.4, and truncating it to an Int16 would hand a client 23 with a Good status. A value that does not fit its declared type, an int16 tag delivering 70000, is reported BadOutOfRange with no value, never wrapped or clamped into something that looks plausible. A numeric tag whose reading carries text is BadTypeMismatch.

Each read (and each subscription sample) answers from the runtime’s live cache, the same source /api/values serves, never from the database. The status code is derived from that reading and the state of the device link behind it, because the cache alone is not enough: it keeps the last reading a device returned, and a device that has since dropped off the network still has a perfectly good-looking last reading in it.

Device link Reading Status Value
Connected, at least one poll delivered this session good Good sent
uncertain Uncertain sent
bad, or an unrecognised quality Bad none
Connected, no poll delivered yet this session any BadWaitingForInitialData none
Connected, nothing cached at all n/a BadWaitingForInitialData none
Degraded (session up, polls failing or partial) good UncertainLastUsableValue sent
uncertain Uncertain sent
bad Bad none
Disconnected, connecting, disabled any BadNoCommunication none
No worker for the device any BadOutOfService none
Tag or device removed n/a BadNodeIdUnknown none

Good is only ever reported for a connected device, on a poll from the current session, whose reading is good. A tag on a disconnected device reads Bad, not its last value with a Good status.

Age is deliberately not part of this. Event-driven sources (an MQTT/Sparkplug tag, an OPC UA subscription) legitimately hold a value for hours because nothing changed, and calling it stale by the clock would be wrong; whether a device is still delivering is the link’s job.

Source timestamp is the reading’s own timestamp, when the device was last heard from, which is reported even on a Bad status. Server timestamp is when the read was answered. Both are UTC.

Clients can create subscriptions and monitored items on tag variables and receive data-change notifications, which is how SCADA clients actually consume tags. Monitoring in this library is by sampling: the server reads each monitored item on its sampling interval (at least 100 ms, and at least the device’s poll interval) through the same handler a Read uses, and notifies when the value or the status changes. So a change in quality, a device dropping off, is announced the same way as a change in value.

A monitored item on a tag that is removed is not left hanging: it receives BadNodeIdUnknown.

What a client sees while the configuration changes

Section titled “What a client sees while the configuration changes”

When a tag is removed, reads of it and monitored items on it get BadNodeIdUnknown straight away, and it drops out of a browse of its device straight away. The node itself stays in the server’s node table for about 5 seconds after that, unreachable, and is then deleted. A removed device works the same way under the Objects folder.

An edit never leaves a gap. An edited tag or device is swapped for its new version under the same NodeId in one step, so a read or browse at that moment sees the old version or the new one, never “unknown”. Subscriptions on it carry on.

The delay before deleting is not decoration. The library’s Browse reads a node’s reference list and then looks up each target without holding a lock across the two, and if one target has vanished in between it fails the browse of the parent with BadNodeIdUnknown, as if the device or the Objects folder did not exist. We used to delete nodes outright, and a client browsing a device while a tag was removed, or reading a tag while its device was edited, could get told a perfectly real node did not exist. Do not go back to deleting nodes directly in space.go: unlink first, delete later. TestAddressSpaceHasNoHolesWhileReconciling catches it if you do.

This increment does not implement writes, and says so in three places at once so no single change can quietly add them:

  • every published node has AccessLevel = CurrentRead and no write handler is installed, so a write is refused BadNotWritable and a client can see before it tries that the node is not writable;
  • the roles the server hands out carry Browse and Read and nothing else, the library’s default provider would make every logged-in user an Operator, which has Write, so this package supplies its own (GetRoles), and TestRolePermissionsAreReadOnly fails if a state-changing permission is ever granted;
  • the tests write to a tag as a viewer, an operator and an admin, and assert the answer is BadNotWritable and that the register on the device behind the tag did not change.

The reason is that this project has one write gate, in internal/runtime: safety.allowWrites (default off), the per-tag writable flag, range checks, and an audit record with a reason for every attempt, including refusals. An OPC UA write path would have to go through that gate, carry its principal into the audit trail, and respect the master switch. That is a change with its own review, not something to arrive as a by-product of publishing tags, and there is deliberately no setting in this version that enables it.

Secure channels use the library’s policies in Sign and SignAndEncrypt. Basic256Sha256 is offered in both modes, and this is what a client should choose.

SecurityPolicy None is an explicit opt-in (HOTLOOP_OPCUA_SERVER_ALLOW_INSECURE, default false), with a startup warning when on. With it off, no None endpoint is advertised, and a client that opens a None channel anyway can only run discovery: every other service is refused. Even with it on, a user name and password still travels inside an encrypted token (the token policy is Basic256Sha256, never None), though every value a client then reads is unprotected.

Known limitation: deprecated policies cannot be turned off. github.com/awcullen/opcua builds its endpoint list itself and offers, in addition to Basic256Sha256 and the Aes128_Sha256_RsaOaep and Aes256_Sha256_RsaPss policies, the deprecated Basic128Rsa15 and Basic256 in both modes. There is no option to remove them, and this package does not fork the library to do so. A client only uses them if it chooses to; a network policy cannot filter them out either, since the choice is made inside the protocol. If your standards forbid them, this server does not meet them yet.

If HOTLOOP_OPCUA_SERVER_CERT_FILE and _KEY_FILE are set, that RSA pair is used as it stands. Otherwise a self-signed application-instance certificate is generated on first start and persisted under HOTLOOP_OPCUA_SERVER_PKI_DIR (own/cert.pem, own/key.pem, key mode 0600), so the next start presents the same certificate and a client that was told to trust it once is not told again. A warning is logged on every start while the certificate is self-signed, with its SHA-1 thumbprint, the form OPC UA tools show, so an operator can compare it with what a client displays.

The certificate says what OPC UA clients check, which an ordinary TLS certificate does not: the application URI (HOTLOOP_OPCUA_SERVER_APPLICATION_URI, default urn:hotloop:gateway, plus :<siteId> when one is configured) in the URI subject alternative names, the host names and addresses clients use (the endpoint host, HOTLOOP_OPCUA_SERVER_CERT_HOSTS, localhost, loopback, this machine’s host name) as DNS and IP SANs, and the key usages Part 6 lists for an application instance. RSA-2048, five years.

A persisted certificate is replaced, with a warning that clients must trust the new one, only if it can no longer do its job: less than 30 days left, an application URI that no longer matches the configuration, a configured host it does not cover, or a key that cannot be read.

The machine’s host name is not a configured host. It goes into a new certificate, but a persisted one is kept when the host name changes. In a container the host name is the container’s id, and in Kubernetes it is the pod’s name, so it changes on every redeploy. Until this was fixed it counted, and every upgrade of a containerised Gateway minted a new certificate: Ignition, told to trust the old one, refused the server with Bad_SecurityChecksFailed until someone trusted it again, on every client, after every deploy. Clients reach the server by HOTLOOP_OPCUA_SERVER_ENDPOINT_HOST and _CERT_HOSTS; set those, and the certificate lives until it nears expiry. (A Gateway that has no endpoint host set uses the machine’s host name as the endpoint host, and that one does count, since it is what clients are told. Another reason to set it.)

A key can be unusable, and the server refuses it. Upstream awcullen/opcua issue 57: when the library decrypts a user-name token it sizes its ciphertext chunks from len(D.Bytes()) rather than from the RSA modulus size. D is a big integer, so its encoding is one byte short whenever its top byte is zero, about one RSA key in twenty, and every encrypted login against that key then fails, every time, with the client seeing only “the secure channel has been closed”. Anonymous and unencrypted sessions are unaffected, which is what makes it easy to misdiagnose. A generated key is drawn again until it is not one of these; an operator-supplied key that is one is refused at start with an error that says why. TestUpstreamShortExponentBug reproduces the failure against the library directly, with no HotLoop code in between, and will start failing when upstream fixes it, at which point the guard in pki.go can go.

OPC UA authenticates the application as well as the user. The server keeps a trust list in <PKI_DIR>/trusted/: a client whose certificate is not there is refused at the secure channel, and its certificate is written to <PKI_DIR>/rejected/. To approve a client, move its certificate from rejected/ to trusted/, no restart needed; the directory is read on every connection. <PKI_DIR>/issuers/ and <PKI_DIR>/crl are passed through for CA-issued client certificates; that path is the library’s own and is not exercised by this repository’s tests.

HOTLOOP_OPCUA_SERVER_TRUST_ALL_CLIENT_CERTS (default false, with a startup warning) skips this step. It removes application authentication only: a user still has to authenticate with a HotLoop username and password.

User name and password, against HotLoop’s own users. There is one user database, not a second credential store: the authenticator looks the user up with the same store.User the HTTP login uses, compares with the same bcrypt hashes (auth.CheckPassword), normalises the user name the same way (lower case, trimmed), runs one bcrypt comparison whether or not the user exists so that response time does not reveal which user names are real, and refuses a disabled account. Unknown user, wrong password, disabled user and a user without the permission all get the same answer, BadUserAccessDenied.

A user may read tags only if their role grants tags.read and values.read, the same pair the HTTP API guards /api/tags and /api/values with, resolved through the same auth.Permissions and auth.Allows, so a built-in role and an explicit permission list behave exactly as they do there. Both are required because an OPC UA client browses the tag list and reads values through one connection, and a user granted only one would otherwise get the other for free. Every built-in role that has values.read also has tags.read; this bites only for a user given an explicit permission list.

Permissions are checked on use, not just at login. The library asks for a session’s roles on every browse, read and subscription sample; those are answered from a short-lived cache (10 s) of the user database. A user who is disabled, deleted, or has permissions removed loses access on a live session within that interval, with their monitored items turning Bad, rather than whenever their client next reconnects. A lookup that fails counts as “no”.

Anonymous sessions are off (HOTLOOP_OPCUA_SERVER_ALLOW_ANONYMOUS) and no anonymous token is offered unless opted into, with a startup warning. X.509 user identity tokens are out of scope and not offered. If HotLoop’s own authentication is off (HOTLOOP_AUTH_INSECURE) there are no users to log in as, and the server refuses to start unless anonymous access is opted into.

A successful login is recorded in the system log the way an HTTP login is. Failed logins are logged to the process log only, with the reason, so an unauthenticated caller cannot fill the audit trail. At most four password checks run at once, since each is a deliberately slow bcrypt comparison and OPC UA has no equivalent of an HTTP rate limiter in front of it; beyond that there is no lockout or rate limit, the same as the HTTP login.

The library keeps the decrypted user identity, password included, in the session object for as long as the session lives. It is memory only and never logged, but a heap dump of a running Gateway would contain the passwords of the users currently connected.

Limit: passwords longer than 64 bytes cannot log in over OPC UA. HotLoop, like bcrypt, accepts up to 72. A user with a 65-72 byte password can log in to the web UI and not to this server (BadIdentityTokenRejected). Give an account that needs OPC UA a password of 64 bytes or fewer.

I can’t fix this from HotLoop’s side, and here is why. The refusal happens inside the library, before it asks HotLoop to check anything, so HotLoop never sees the login and its log has no line for it. In github.com/awcullen/opcua v1.4.0 (the latest release), server/server_service_set.go decrypts the password, reads the length the client put in front of it, and rejects the session when that length is under 32 or over 96 (lines 603, 663 and 723, one per security policy). The length counts the password plus the 32-byte server nonce, so the password tops out at 64 bytes. The endpoints and their token policies are built inside the library too (buildEndpointDescriptions in server/server.go) and there is no option to change either, so the only fix is a change to the library: raise that 96 (nothing in RSA forces it, a 2048-bit key carries more than that per block), or ship a fork. Searching that repository’s issues and pull requests for the password limit, 64 bytes and plainLength turns up nothing; its open #57 and #62 are about a different decrypt bug (the buffer size when the key has a leading zero byte). No issue is filed for this one yet. TestAuthentication pins the edge at 64 and 65 bytes so the day a library upgrade moves it, the test fails and this paragraph gets rewritten.

The library’s per-session diagnostics nodes would let any logged-in user browse every other session’s user name and client address, so they are switched off, and the Server/ServerDiagnostics object is removed from the address space (its counters are also written without a lock by the library).

Devices and tags are added, edited and removed while the Gateway runs, and the address space follows without a restart. Mechanism: a runtime callback, plus a timer as a safety net.

  • runtime.Manager.OnReload calls back after every successful Reload. Every device and tag mutation the API makes ends in a Reload, so a change made through the UI or API is published within moments. The callback only signals a reconcile goroutine; it never does the work itself, so a slow reconcile cannot stall the API request that triggered it.
  • A periodic reconcile (HOTLOOP_OPCUA_SERVER_RECONCILE_INTERVAL, default 30 s) catches anything that changes configuration without a Reload.

Both run the same idempotent diff against the enabled devices and tags in the database. It touches only what differs: an edited tag is replaced, a removed one is deleted, and everything else, including the monitored items clients hold on it, is left exactly as it was. The nodes of a new device go into the space in one call, so a client browsing mid-reconcile sees a device with all of its tags or none of it.

A callback was preferred to polling alone because the runtime already has the one clean signal (Reload) and it makes a change visible in milliseconds instead of up to a full interval; the timer stays because the address space should not depend on every future writer of configuration remembering to reload.

The endpoint is opc.tcp://<HOTLOOP_OPCUA_SERVER_ENDPOINT_HOST>:<port>. In a cluster the chart defaults the host to the release’s Service DNS name; outside one, set it to the name or address clients will actually use (a LoadBalancer address, a DNS record), it is advertised in every endpoint URL and written into the generated certificate. It is not where the server binds: the library listens on every interface and takes only a port.

With UaExpert or any other client:

  1. Add the server with that URL and choose Basic256Sha256 / Sign & Encrypt.
  2. Choose Username, and log in with a HotLoop user that has tags.read and values.read.
  3. The first connection is refused because the server does not yet trust the client’s certificate. Find it in <PKI_DIR>/rejected/ (on the PKI volume in a cluster), move it to trusted/, and connect again.
  4. The first time, the client asks whether to trust the server’s certificate; compare the thumbprint with the one in the start-up log.
  5. Browse Objects → the device → its tags, and drag tags into the data-access view; they subscribe.

Verified on 2026-09-27 against Ignition 8.3.9, the current stable release, in Inductive Automation’s own container image (inductiveautomation/ignition:8.3.9, standard edition on its trial), against a HotLoop Gateway whose demo device extruder-1 is fed by a simulator. What was shown, end to end:

  • an Ignition OPC UA connection to opc.tcp://<gateway>:4840 with Basic256Sha256 and SignAndEncrypt, signed in as a HotLoop viewer;
  • the certificate trust in both directions, as below;
  • OPC tags in Ignition’s [default] provider pointing at HotLoop nodes, reading with Good quality and changing as the simulator moves. Every value Ignition showed was one HotLoop’s own /api/values had held: the same double crosses the wire unchanged, so the lab compares them for equality, not closeness. A text tag declared string in HotLoop read “RUNNING” in an Ignition String tag;
  • a write from Ignition refused: Ignition sent the write, HotLoop answered Bad_NotWritable (read off the wire over a Sign-only channel, where the response is signed but not encrypted), and HotLoop’s value did not move;
  • every security policy and mode Ignition offers, below.

test/ignition/lab.py does all of it again and prints PASS or FAIL for each step; see its README.

Not verified: browsing HotLoop from the Designer’s OPC browser (the Designer was not used; the tags were created by item path, which is how the lab creates them), Ignition’s historian on these tags, and anything longer than the lab’s runs.

On the HotLoop side, before Ignition:

  1. Turn the server on and set HOTLOOP_OPCUA_SERVER_ENDPOINT_HOST to the name Ignition reaches the Gateway by. That name is in every endpoint URL the server advertises and in its certificate.
  2. Create a HotLoop user for Ignition with the viewer role (an admin does it with POST /api/users; see AUTH.md). Any role with tags.read and values.read signs in; nothing more is offered, since the server is read-only.

In the Ignition Gateway (the web UI on port 8088):

  1. On the OPC Connections page under Connections (/app/connections/opc-client-connections), create an OPC UA connection. Endpoint URL opc.tcp://<gateway>:4840; choose the Basic256Sha256 / SignAndEncrypt endpoint; authentication Username, with the HotLoop user and password. The connection goes Faulted at once, which is expected: step 4.

  2. Approve Ignition in HotLoop. Ignition’s client certificate is now in the Gateway’s <PKI_DIR>/rejected/ (/data/opcua-pki/rejected in the container and the chart), named by its SHA-1 thumbprint, <thumbprint>.crt. Compare the thumbprint with Ignition’s own OPC UA client certificate, shown on the OPC certificates page (/app/connections/opc/security/certificates). Then move the file to trusted/. No restart on either side. HotLoop does not log the refusal; the file is the only sign of it.

  3. Approve HotLoop in Ignition. The connection now fails the other way: the status says Bad_SecurityChecksFailed ... unable to find valid certification path. HotLoop’s certificate is in Ignition’s rejected client certificates on the same page. Check its thumbprint against the one HotLoop logs at start (generated a self-signed OPC UA application certificate ... thumbprint=) and trust it. No restart; the connection comes up within seconds.

  4. Tags. Create OPC tags with the connection as the OPC server and item paths in the namespace-URI form, which Ignition accepts as it stands:

    nsu=urn:hotloop:ua:gateway;s=extruder-1/Emberburn_Temperature

    Declare each one as what HotLoop publishes (see Data types): Float8 for a float, Boolean, String, Int2 for an int16, Int4 for a uint16 or int32, Int8 for the wider integers. They subscribe on the tag group’s rate, and HotLoop revises a faster sampling rate up to the device’s poll interval.

The same, as files or through the REST API (which is how the lab does it):

  • The connection is the resource data/config/resources/core/ignition/opc-connection/<name>/config.json, profile type com.inductiveautomation.OpcUaServerType, or POST /data/api/v1/resources/ignition/opc-connection. Two things in it are not obvious. The password is an embedded secret, a JWE that POST /data/api/v1/encryption/encrypt makes from the plain text; a plain string is not accepted. And the security block needs the gateway’s client key store alias and its password: leave it out and the connection fails with a NullPointerException, give an empty alias and it fails with Unable to retrieve KeyPair for alias 'client'. Copy the block from the stock Ignition OPC UA Server loopback connection, which carries both.
  • The certificates are under data/config/local/com.inductiveautomation.opcua/client/security/pki/ (rejected/certs, trusted/certs), or GET /data/opc-ua/api/v1/client/pki/certificates/rejected and POST .../trusted/<sha1> through the API. Ignition does not name the files by the thumbprint; the API’s signature field is the SHA-1 thumbprint, and it matches what HotLoop logs.
  • Tags go in with POST /data/api/v1/tags/import?provider=default&type=json.
  • Every API call needs an API key (the API Keys page, /app/platform/security/api-keys), and a key only gets through if one of its security levels is in the gateway’s read or write permissions (the security-properties resource). test/ignition/lab.py shows one way to set that up from files on a fresh gateway.
  • Bad_SecurityChecksFailed means two different things. Ignition shows the same status when HotLoop refuses Ignition’s certificate (step 4; the message has no reason) and when Ignition refuses HotLoop’s (step 5; the message says unable to find valid certification path). Look in HotLoop’s rejected/ first.
  • A redeploy of HotLoop keeps its certificate. Up to 4.15.3 it did not: the certificate had to cover the container’s host name, which changes whenever the container is recreated, so every upgrade minted a new certificate and Ignition refused the server until someone trusted it again. That is how this was found, when the lab’s HotLoop container was recreated mid-test. See Server certificate. The certificate still changes if the PKI volume is lost.
  • A text tag declared numeric reads Bad_TypeMismatch. HotLoop publishes a tag as the type it is declared, and a float tag holding “RUNNING” cannot be a Double. Tags created by Discover tags up to 4.15.3 were all declared float; see PROTOCOLS.md. Declare them string.
  • Writes are refused, and Ignition blurs why. HotLoop answers Bad_NotWritable. Ignition’s own OPC UA server, asked to write the same tag, passes on only a generic Bad (0x80000000). The Ignition tag’s value does not change.
  • The deprecated policies work, which is the point to note. Ignition offers Basic128Rsa15 and Basic256, HotLoop cannot turn them off (see Transport), and Ignition connects with them. Choose Basic256Sha256 or one of the Aes policies in the connection.
  • The trial. An unlicensed 8.3 gateway runs for two hours and then stops executing until the trial is reset in the Gateway web UI. Tags go stale when it lapses; that is Ignition, not the connection.

Each row is a fresh session with that policy and mode, reading live values:

Policy SignAndEncrypt Sign
Basic256Sha256 works works
Aes128_Sha256_RsaOaep works works
Aes256_Sha256_RsaPss works works
Basic256 (deprecated) works works
Basic128Rsa15 (deprecated) works works

None is not offered unless HOTLOOP_OPCUA_SERVER_ALLOW_INSECURE is set, and was not tried. Aes256_Sha256_RsaPss works here although it fails between HotLoop’s own driver and opc-plc: that failure is the driver’s library, not this server.

Variable Default Details
HOTLOOP_OPCUA_SERVER_ENABLED false The feature is off until this is set
HOTLOOP_OPCUA_SERVER_PORT 4840 TCP port, on every interface
HOTLOOP_OPCUA_SERVER_ENDPOINT_HOST this machine’s host name What clients are told to connect to; also written into a generated certificate. Set this.
HOTLOOP_OPCUA_SERVER_APPLICATION_URI urn:hotloop:gateway[:<siteId>] Must stay stable across restarts; must be a URI
HOTLOOP_OPCUA_SERVER_PKI_DIR /data/opcua-pki Certificate, key, trusted/, rejected/. Back it with a volume
HOTLOOP_OPCUA_SERVER_CERT_FILE / _KEY_FILE n/a Use this RSA pair instead of generating one; set both or neither
HOTLOOP_OPCUA_SERVER_CERT_HOSTS n/a Extra DNS names / IPs for a generated certificate
HOTLOOP_OPCUA_SERVER_ALLOW_INSECURE false Offer SecurityPolicy None. Warned about at startup
HOTLOOP_OPCUA_SERVER_ALLOW_ANONYMOUS false Accept sessions with no identity. Warned about at startup
HOTLOOP_OPCUA_SERVER_TRUST_ALL_CLIENT_CERTS false Skip client-certificate approval. Warned about at startup
HOTLOOP_OPCUA_SERVER_MAX_SESSIONS 20 0 = unlimited
HOTLOOP_OPCUA_SERVER_MAX_SUBSCRIPTIONS 200 0 = unlimited
HOTLOOP_OPCUA_SERVER_RECONCILE_INTERVAL 30s The safety-net reconcile period

The server is not started in --mcp-stdio mode: that is an agent spawning this binary as a helper, and a second process contending with the real Gateway for port 4840 is not something that should be possible.

opcuaServer.* in charts/hotloop/values.yaml. Enabling it adds a Service (<release>-opcua, ClusterIP by default, LoadBalancer or NodePort is a deliberate choice for a client outside the cluster), a PVC for the PKI directory (opcuaServer.persistence; losing it means every client must trust a new certificate), the container port, and, so that networkPolicy.enabled and opcuaServer.enabled together do not silently leave a server listening that nothing can reach, the port in the default NetworkPolicy ingress rule. A custom networkPolicy.ingress replaces that rule, as it does for the broker, and must then list the port itself. opcuaServer.tls.existingSecret supplies a kubernetes.io/tls Secret instead of a generated certificate.

github.com/awcullen/opcua v1.4.0 (MIT), the only Go OPC UA library found that ships a real server package. Choices worth knowing:

  • v1.4.0, the latest tag (December 2024), not master. Master has newer commits (through February 2026), but the difference is a DeleteNode API refactor, not fixes for anything here; a pinned release is what this repository does everywhere else.
  • Six modules join the build: the library itself, djherbis/buffer (MIT), gammazero/deque (MIT), gammazero/workerpool (MIT), google/uuid (BSD-3) and pkg/errors (BSD-2). The ~39 figure in the search results is the whole module graph including test-only dependencies; none of those are compiled in. All licences are permissive, none is vendored or modified, so NOTICE, which lists only vendored or modified code and non-permissive licences, is unchanged.
  • No cgo. The binary still builds with CGO_ENABLED=0 and runs on distroless/static.
  • Binary size: linux/amd64, -trimpath -ldflags="-s -w", went from 23,634,082 to 28,598,434 bytes (+4.7 MiB, +21%). About 2.9 MB of that is the library’s embedded OPC UA nodeset XML.
  • Two OPC UA libraries in one binary. internal/driver uses github.com/gopcua/opcua for the client; the server uses awcullen/opcua. They share no packages or global state; it builds and runs. The client was not migrated.

Beyond issue 57 above (worked around) and the deprecated policies (documented):

  • A data race in Server.Close, found by the race detector, not by reading. Close writes the shutdown reason and the seconds-till-shutdown countdown without a lock while the ServerStatus read handlers read them from the sampling goroutines, so any client that monitors ServerStatus, most SCADA clients do, races the server’s own shutdown. This package answers those three nodes itself, from state it guards, and reproduces the countdown (status.go); TestShutdownWhileServerStatusIsMonitored fails under -race without that.
  • Server.Close panics if called twice (it closes a channel unconditionally) and sleeps three seconds first, so Stop takes a few seconds. Stop is guarded to close once.
  • ListenAndServe binds :<port> on every interface and has no bind-address option; a failed listen leaves the server’s background goroutines running (upstream issue 43), which Start cleans up by closing it.
  • A discovery-only channel dereferences DiscoveryURLs[0], which panics on the first unauthenticated connection if the application description has none; it is always set here.
  • Unsynchronised diagnostic counters (from reading the code; not reproduced): the library increments its serverDiagnosticsSummary counters from every connection’s goroutine without synchronisation whenever a login is refused, so two clients refused at the same instant would race. Only counters could be corrupted, and nothing here reads them (the subtree is removed), but the writes themselves are upstream’s and remain.
  • One polling goroutine and ticker per distinct sampling interval, never reclaimed until the server closes; a client using many distinct intervals grows them, bounded by the interval range (100 ms to 60 s).
  • Empty token policy id (fixed in 4.15.0): the library requires a client to echo the token policy id from the endpoint list, as the specification says. This repository’s own OPC UA driver used to build its client with AuthAnonymous() and AuthUsername() and no endpoint selection, so it sent an empty policy id and could not open a session against this server in any mode (BadIdentityTokenInvalid). That was a fault in the driver, found by trying it against this server. The driver now asks the server for its endpoints, picks the one that matches the device, and echoes the policy id it listed, so this Gateway can be a device of another Gateway: a user name and password over SignAndEncrypt works, the driver’s certificate is approved by moving it from rejected/ to trusted/ here, and this server’s certificate is approved on the driver’s side the same way. The refusal of a client that still omits the id is kept as a test, as the control that shows the server was right. See PROTOCOLS.md.

Tested, not verified, this repository’s own distinction between the two. Every test client is github.com/gopcua/opcua, a genuinely independent implementation: the server is awcullen/opcua and the two share no code, so a passing test means two implementations agree on the wire. Everything behind the server is real, a real Modbus server holding real registers, the real driver, runtime and cache polling it, a real TimescaleDB holding real users with real bcrypt hashes, and nothing is mocked.

Tested, with a gopcua client over Basic256Sha256 / SignAndEncrypt and a user name and password:

  • browsing the address space (device objects, tag variables, properties);
  • reading a tag and asserting value, declared data type, status, source and server timestamps, for every mapped type;
  • creating a subscription and receiving data-change notifications when the underlying register changes, when a device is unplugged (the tag turns Bad) and when it returns;
  • a tag on a disconnected device reading Bad even though a Good reading is in the cache, and a partly failing device reading no better than Uncertain;
  • the address space following devices and tags being added, edited, removed and switched off, by reload and by the periodic reconcile, including a monitored item on a removed tag turning BadNodeIdUnknown;
  • start, stop, restart and stop-while-connected, with the certificate surviving restarts, a port already in use, and a client monitoring ServerStatus during shutdown;
  • the negative paths, each of which fails for the reason under test (the error carries the specific OPC UA status) and each of which has a matching acceptance from the same client code: a wrong password, an unknown user, a disabled user, a user whose role lacks tags.read or values.read, anonymous with anonymous access off, SecurityPolicy None with insecure mode off, a client certificate that is not trusted (and its approval), a write to a tag as viewer, operator and admin, and permission revoked on a live session;
  • the whole suite under go test -race -count=10.

Verified against Ignition 8.3.9, a production SCADA client built on a third OPC UA stack (Eclipse Milo): reads, subscriptions, a refused write, and every security policy it offers, including the Aes* and the deprecated ones. See Ignition 8.3 as a client.

Not verified against UaExpert, KEPServerEX, WinCC, or any OPC Foundation-certified client or the OPC UA compliance test tool. Also not verified: CA-issued client certificates and CRLs; behavior with thousands of tags or hundreds of subscriptions; IPv6; and any client that expects features this server does not implement (history, events, methods, writes, X.509 user tokens).