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.
What is exposed
Section titled “What is exposed”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.
NodeIds
Section titled “NodeIds”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.
Attributes
Section titled “Attributes”| 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.
Data types
Section titled “Data types”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.
Value, quality and timestamps
Section titled “Value, quality and timestamps”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.
Subscriptions
Section titled “Subscriptions”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.
Why it is read-only
Section titled “Why it is read-only”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 = CurrentReadand no write handler is installed, so a write is refusedBadNotWritableand 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), andTestRolePermissionsAreReadOnlyfails 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
BadNotWritableand 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.
Security model
Section titled “Security model”Transport
Section titled “Transport”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.
Server certificate
Section titled “Server certificate”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.
Client application certificates
Section titled “Client application certificates”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.
Who may log in
Section titled “Who may log in”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.
Server diagnostics are removed
Section titled “Server diagnostics are removed”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).
Address-space changes at runtime
Section titled “Address-space changes at runtime”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.OnReloadcalls back after every successfulReload. Every device and tag mutation the API makes ends in aReload, 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 aReload.
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.
Connecting a client
Section titled “Connecting a client”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:
- Add the server with that URL and choose
Basic256Sha256/Sign & Encrypt. - Choose Username, and log in with a HotLoop user that has
tags.readandvalues.read. - 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 totrusted/, and connect again. - The first time, the client asks whether to trust the server’s certificate; compare the thumbprint with the one in the start-up log.
- Browse Objects → the device → its tags, and drag tags into the data-access view; they subscribe.
Ignition 8.3 as a client
Section titled “Ignition 8.3 as a client”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>:4840withBasic256Sha256andSignAndEncrypt, 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/valueshad held: the same double crosses the wire unchanged, so the lab compares them for equality, not closeness. A text tag declaredstringin 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.
Setting it up
Section titled “Setting it up”On the HotLoop side, before Ignition:
- Turn the server on and set
HOTLOOP_OPCUA_SERVER_ENDPOINT_HOSTto the name Ignition reaches the Gateway by. That name is in every endpoint URL the server advertises and in its certificate. - Create a HotLoop user for Ignition with the viewer role (an admin does it
with
POST /api/users; see AUTH.md). Any role withtags.readandvalues.readsigns in; nothing more is offered, since the server is read-only.
In the Ignition Gateway (the web UI on port 8088):
-
On the OPC Connections page under Connections (
/app/connections/opc-client-connections), create an OPC UA connection. Endpoint URLopc.tcp://<gateway>:4840; choose theBasic256Sha256/SignAndEncryptendpoint; authentication Username, with the HotLoop user and password. The connection goes Faulted at once, which is expected: step 4. -
Approve Ignition in HotLoop. Ignition’s client certificate is now in the Gateway’s
<PKI_DIR>/rejected/(/data/opcua-pki/rejectedin 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 totrusted/. No restart on either side. HotLoop does not log the refusal; the file is the only sign of it. -
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. -
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_TemperatureDeclare each one as what HotLoop publishes (see Data types): Float8 for a float, Boolean, String, Int2 for an
int16, Int4 for auint16orint32, 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 typecom.inductiveautomation.OpcUaServerType, orPOST /data/api/v1/resources/ignition/opc-connection. Two things in it are not obvious. The password is an embedded secret, a JWE thatPOST /data/api/v1/encryption/encryptmakes from the plain text; a plain string is not accepted. And thesecurityblock 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 withUnable to retrieve KeyPair for alias 'client'. Copy the block from the stockIgnition OPC UA Serverloopback connection, which carries both. - The certificates are under
data/config/local/com.inductiveautomation.opcua/client/security/pki/(rejected/certs,trusted/certs), orGET /data/opc-ua/api/v1/client/pki/certificates/rejectedandPOST .../trusted/<sha1>through the API. Ignition does not name the files by the thumbprint; the API’ssignaturefield 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 (thesecurity-propertiesresource).test/ignition/lab.pyshows one way to set that up from files on a fresh gateway.
What to expect
Section titled “What to expect”Bad_SecurityChecksFailedmeans 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’srejected/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 afloattag holding “RUNNING” cannot be a Double. Tags created by Discover tags up to 4.15.3 were all declaredfloat; see PROTOCOLS.md. Declare themstring. - 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 genericBad(0x80000000). The Ignition tag’s value does not change. - The deprecated policies work, which is the point to note. Ignition offers
Basic128Rsa15andBasic256, HotLoop cannot turn them off (see Transport), and Ignition connects with them. ChooseBasic256Sha256or one of theAespolicies 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.
Policy matrix
Section titled “Policy matrix”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.
Configuration
Section titled “Configuration”| 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.
Dependency
Section titled “Dependency”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
DeleteNodeAPI 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) andpkg/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, soNOTICE, which lists only vendored or modified code and non-permissive licences, is unchanged. - No cgo. The binary still builds with
CGO_ENABLED=0and runs ondistroless/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/driverusesgithub.com/gopcua/opcuafor the client; the server usesawcullen/opcua. They share no packages or global state; it builds and runs. The client was not migrated.
What was found in the library
Section titled “What was found in the library”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.Closewrites the shutdown reason and the seconds-till-shutdown countdown without a lock while theServerStatusread handlers read them from the sampling goroutines, so any client that monitorsServerStatus, 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);TestShutdownWhileServerStatusIsMonitoredfails under-racewithout that. Server.Closepanics if called twice (it closes a channel unconditionally) and sleeps three seconds first, soStoptakes a few seconds. Stop is guarded to close once.ListenAndServebinds:<port>on every interface and has no bind-address option; a failed listen leaves the server’s background goroutines running (upstream issue 43), whichStartcleans 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
serverDiagnosticsSummarycounters 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()andAuthUsername()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 overSignAndEncryptworks, the driver’s certificate is approved by moving it fromrejected/totrusted/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.
What has and has not been verified
Section titled “What has and has not been verified”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
ServerStatusduring 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.readorvalues.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).