Skip to content

Reverse Connect

Kevin Herron edited this page Oct 12, 2026 · 11 revisions

Milo 1.2.0-RC1, Java 17. Source baseline 69cb25087804.

Reverse Connect lets a server open the TCP socket to a listening client. You need it when the client cannot reach the server, for example when a firewall lets a plant-floor server make outbound connections but blocks inbound ones. Once the socket is open, the normal SecureChannel, certificate validation, Session, and user-authentication workflow follows, so Client security and Server security still apply in full. This first feature guide uses both the client and the server SDK from the previous two sections.

How a reverse connection is made

Two applications take part. The client application runs a listener, a ReverseConnectManager bound to an address the server can reach. The server application holds a list of targets, each naming a client listener URL, and keeps dialing them. When a socket opens, the server sends a ReverseHello carrying its server URI and the endpoint URL it offers. The manager holds the socket as a pending candidate until a selector in the client claims it. The claimed socket then carries an ordinary OPC UA handshake, with the client still choosing the endpoint and validating the server.

The ReverseHello fields are unauthenticated routing hints. They decide which selector claims a candidate, but they do not prove which server connected. Restrict who can reach the listener, admit candidates deliberately, and validate the peer normally when the SecureChannel and Session are established.

Start a listener in the client application

The listener belongs to the client application and normally lives as long as it does. Bind it to an address the server can reach. A loopback listener is reachable only from the same host, so choose the deployment address deliberately. This method starts a listener whose verifier rejects a ReverseHello carrying an unexpected server URI, so that candidate never takes a pending slot.

static ReverseConnectManager startReverseListener(
    InetSocketAddress bindAddress, String expectedServerUri) throws Exception {
  // ServerUri is an unauthenticated hint. This check only filters, and the
  // SecureChannel and Session handshakes still validate the server.
  ReverseHelloVerifier verifier =
      candidate -> {
        if (expectedServerUri.equals(candidate.serverUri())) {
          return ReverseConnectVerificationResult.accept();
        }
        return ReverseConnectVerificationResult.reject("Unexpected ServerUri");
      };

  ReverseConnectManager manager =
      ReverseConnectManager.builder()
          .addBindAddress(bindAddress)
          .setReverseHelloVerifier(verifier)
          .build();

  manager.startup();
  return manager;
}

Supply at least one bind address. startup() binds synchronously and throws if binding fails, so a port conflict surfaces when the application starts. The verifier runs after the ReverseHello is decoded, and a rejected candidate never reaches a selector. The manager defaults are:

  • 5-second first-message timeout
  • 30-second pending hold
  • 64 pending candidates
  • 1,024 retained candidate snapshots
  • accept-all ReverseHello verifier

snapshot() reports the bound listener addresses and the state of each candidate, and addListener delivers manager events as they happen.

Connect a client through the listener

The client also runs in the client application. It needs the server's endpoint before the first connection arrives, because in a reverse-only network it cannot dial the server to ask. The OpcUaClientConfig therefore comes from out-of-band provisioning, an EndpointDescription your deployment stores and passes to OpcUaClientConfig.builder().setEndpoint(...), or from the discovery-first workflow. For deployment, supply certificate identity, explicit trust, a secured endpoint, and user authentication as in Client security. Use an anonymous None endpoint only for a local test.

This method creates a client whose selector matches the configured server URI and endpoint URL, then connects.

static OpcUaClient connectReverse(ReverseConnectManager manager, OpcUaClientConfig config)
    throws Exception {
  EndpointDescription endpoint = config.getEndpoint();
  String serverUri = endpoint.getServer().getApplicationUri();
  String endpointUrl = endpoint.getEndpointUrl();

  ReverseConnectSelector selector =
      ReverseConnectSelector.byServerUriAndEndpointUrl(serverUri, endpointUrl);
  OpcUaClient client = OpcUaClient.createReverseConnect(config, manager, selector);

  try {
    client.connectAsync().get(60, TimeUnit.SECONDS);
  } catch (Exception e) {
    // Disconnect removes the selector, so a server that arrives later is not claimed.
    try {
      client.disconnectAsync().get(10, TimeUnit.SECONDS);
    } catch (Exception disconnectFailure) {
      e.addSuppressed(disconnectFailure);
    }
    throw e;
  }
  return client;
}

OpcUaClient.createReverseConnect(...) accepts either a manager and selector, as here, or a preclaimed connection. The connect completes when a matching server has connected and the Session is open, so the wait includes the time until the server's next attempt. The result is an ordinary connected client that you use as in the rest of the client guide, and it never needs to dial the advertised server endpoint. When the connection drops later, the transport requests a new matching candidate, and recovery proceeds as in Recovery and limits.

The method disconnects before it rethrows a failure, because ending the wait is not enough. If no matching server arrives, the connection stays pending. Future.get(timeout) only ends the caller's wait, and cancelling the OpcUaClient.connectAsync() future does not reach the transport. Neither removes the selector, so a later matching server could still be claimed and open a Session. OpcUaClient.disconnectAsync() removes the selector and stops rearming.

Add a target in the server application

The other half runs in the server application, on a host that can reach the client's listener. Add a target with server.addReverseConnectTarget at runtime, or with OpcUaServerConfigBuilder.addReverseConnectTarget or setReverseConnectTargets before startup. This method adds one target at runtime.

static ReverseConnectTargetHandle addClientTarget(
    OpcUaServer server, String clientListenerUrl, String endpointUrl) {
  ReverseConnectTarget target =
      ReverseConnectTarget.builder()
          .setClientListenerUrl(clientListenerUrl)
          .setEndpointUrl(endpointUrl)
          .setConnectTimeout(uint(5_000))
          .setRegistrationPeriod(uint(30_000))
          .build();

  // Registration only. The target manager makes and retries the connection attempts.
  return server.addReverseConnectTarget(target);
}

The client listener URL is the listener's address as the server sees it, such as opc.tcp://client.example.com:48060. The endpoint URL is the server endpoint advertised in ReverseHello, and it must match a configured opc.tcp endpoint by full URL or path. The client's selector expects the same URL. The timeout and period shown are the defaults.

A return from addReverseConnectTarget does not mean a Session was opened. Report actual progress from target snapshots and from addReverseConnectTargetListener events, including CONNECTING, HANDOFF, CLIENT_ERROR, FAILED, CANCELLED, and CLOSED. This listener logs every attempt transition.

static void logReverseConnectAttempts(OpcUaServer server) {
  server.addReverseConnectTargetListener(
      new ReverseConnectTargetListener() {
        @Override
        public void onAttemptEvent(ReverseConnectAttemptEvent event) {
          LOGGER.info(
              "Reverse Connect target {} attempt {}: {} {}",
              event.targetId(),
              event.attemptNumber(),
              event.state(),
              event.statusCode());
        }
      });
}

Register the listener once per server. Its callbacks run on the server's executor, so keep them short.

Target settings and retries

Setting Builder method Default
Client listener URL setClientListenerUrl Required
Server endpoint URL advertised in ReverseHello setEndpointUrl Required
Connect timeout setConnectTimeout uint(5_000) (5 seconds)
Registration period setRegistrationPeriod uint(30_000) (30 seconds)
Enabled and paused state setEnabled, setPaused Enabled and unpaused
Retry policy setRetryPolicy registrationPeriod()

setRegistrationPeriod, setConnectTimeout, and fixedDelay take UInteger milliseconds. updateReverseConnectTarget keeps the target's ID, cancels in-flight work, and leaves active channels open.

The target manager owns retries and keeps at most one in-flight attempt per target, so do not create a second application retry loop that races it. The built-in retry policies are registrationPeriod() and fixedDelay(UInteger). Exponential backoff requires a custom ReverseConnectRetryPolicy. Choose delays that limit network pressure while the listener is down.

The returned handle supports pause, resume, trigger, snapshot, and remove. Await asynchronous control operations when later work depends on them. Pausing cancels scheduled and in-flight attempts but leaves existing channels open. Removal also closes tracked active channels. trigger is ignored while the target is disabled, paused, stopped, or already pending.

Shut down in order

Each application owns what it created. Shut down in this order:

  1. Stop accepting new application work.
  2. In the client application, disconnect owned clients.
  3. In the server application, remove targets that should not reconnect.
  4. In the client application, shut down the manager or acceptor you own.

A matching server that arrives after disconnect stays pending and unclaimed. Closing the manager releases the listener port. A shared manager outlives any one client, so that client's ordinary cleanup must not close it. Manager shutdown does not close claimed channels, so perform timeout and cancellation cleanup explicitly.

Choose a client entry point

The manager-and-selector path above suits a client that already knows its server's endpoint. The other entry points cover other situations.

Client entry point Use and ownership
ReverseConnectManager plus selector Known endpoint configuration, from discovery-first or out-of-band provisioning in a reverse-only network. The transport requests a new matching candidate when reconnecting.
Preclaimed ReverseConnectConnection The application explicitly claims a pending candidate. The connection is one-shot, so a later reconnect needs another connection or client. Close an unused claim with ReverseConnectConnection.close().
DiscoveryFirstReverseConnectClient Discovers endpoints over one reverse connection, selects and configures the endpoint, then opens the production Session on a later connection.
ReverseConnectAcceptor Repeats discovery-first handling for several servers on a shared listener, with admission control for duplicate and in-flight candidates.

Claim candidates yourself

registerSelector, claim(UUID), reject(UUID, StatusCode, String), addListener, and snapshot() manage candidates directly. A selector matches pending candidate metadata, and a successful claim consumes the candidate and its one-shot registration. Manager-and-selector transports rearm as needed, but raw registrations and preclaimed connections do not form a reusable connection pool.

registerSelector returns a ReverseConnectRegistration. Cancelling its connectionFuture() or completing it exceptionally, including through orTimeout, removes the selector and closes any claim that can no longer be delivered. The CLAIMED state records an ownership reservation, so wait for the connection future before using the connection.

You own a delivered ReverseConnectConnection. Pass it to OpcUaClient.createReverseConnect(config, connection) or close it. To abandon a registration, cancel its future and close any connection it already delivered. ReverseConnectRegistration.close() removes only an unmatched selector. A racing claim can still deliver a connection afterward, which you must use or close.

Discover endpoints over the first connection

A discovery-first workflow consumes its first reverse channel to run GetEndpoints, then opens the production Session on the next matching reverse connection. The server must keep making attempts after the discovery connection closes. Do not try to turn a consumed discovery socket into the production Session.

Its defaults suit only a local trial. By default, the discovery-first client and the acceptor:

  • accept any candidate
  • select a None endpoint that allows anonymous activation
  • create an anonymous client with Session endpoint validation disabled

Deployments must override these defaults with setDiscoverySelector, setEndpointSelector, and setClientConfig to select the intended peer, secured endpoint, identity, and trust, and must set setSessionEndpointValidationEnabled(...) deliberately.

To abandon a discovery-first attempt, cancel the future returned by DiscoveryFirstReverseConnectClient.connectAsync(), and use that explicit cancellation to enforce a deadline. Applying orTimeout to this future completes it exceptionally without running its cancellation cleanup. A successful cancellation closes the discovery registration and any claimed discovery connection, and disconnects any production client the helper created. If the future already delivered a client, you own and must disconnect it.

Share a listener across servers

For a shared listener, use the acceptor's hooks for candidate selection, client configuration, and deduplication. Its default server-URI key is an admission key, not an authenticated identity. Track accepted client lifecycles, and handle duplicate and failed candidates. Stop acceptance before shutting down clients and the listener.

Security

Reverse Connect changes who dials TCP, not who trusts whom. Keep validating:

  • server certificate chains
  • host and application identity under the selected endpoint policy
  • client application identity
  • Session user permissions

A socket accepted through a firewall is not an authorization decision. Validate interoperability and routing for each peer and network deployment.

When things go wrong

A reverse connection fails in stages, and each stage reports in its own place. Manager events distinguish socket acceptance, ReverseHello rejection, pending state, and claim. A later SecureChannel or Session failure appears on the client, not in the manager snapshot, and the manager-backed transport can rearm after it. The connect timeout, candidate hold timeout, SecureChannel timeout, Session activation, and application wait deadline each describe a different failure stage, so record which one expired.

Symptom Likely cause What to do
The client's connect does not complete No matching server has connected, so the connection stays pending Check the server's target snapshots and attempt events, and compare the advertised server URI and endpoint URL with the selector
Candidate rejected by the manager The candidate snapshot carries the reason, such as a verifier rejection, an expired pending hold, a full pending limit, or no valid first message in time Fix what the reason points to
Server attempt reaches CLIENT_ERROR The client answered with an ErrorMessage instead of Hello, which the manager sends when it rejects a candidate Check the manager's rejection reason on the client side
Server attempt reaches FAILED The attempt failed before the channel entered the normal server path, for example because the listener was unreachable Check the client listener URL and network route. The target manager retries on its own.
IllegalArgumentException when adding a target The endpoint is unusable or the target ID is a duplicate Match a configured opc.tcp endpoint and use a new ID
IllegalStateException when adding a target The target manager has shut down Add targets only while the server runs or through its configuration
SecureChannel or Session failure after a claim Certificate, endpoint, or user validation failed Handle it as an ordinary client connection failure, see Client security

Known limitations

At this baseline, Milo clients require client-initiated (forward) OPC TCP for ECC or RSA-DH Sessions. Of the bundled client transports, only OpcTcpClientTransport defers building channel-bound requests and supplies the active channel thumbprint. ReverseTcpClientTransport inherits an empty thumbprint, so it does not supply the binding that enhanced Session authentication requires. When you need those policies, connect forward. Over Reverse Connect, choose a SecurityPolicy outside the ECC and RSA-DH families, such as Basic256Sha256 or Aes256_Sha256_RsaPss. See Transport extensions.

Examples and reference

Next steps

You can now connect through a firewall in the reverse direction. To have a Global Discovery Server issue your application's certificates and trust lists instead of managing them by hand, continue with GDS client.

Related: Client SDK · Server SDK · Client security · Server security

Clone this wiki locally