/* * The contents of this file are subject to the terms of the Common Development and * Distribution License (the License). You may not use this file except in compliance with the * License. * * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the * specific language governing permission and limitations under the License. * * When distributing Covered Software, include this CDDL Header Notice in each file and include * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions Copyright [year] [name of copyright owner]". * * Copyright 2006-2009 Sun Microsystems, Inc. * Portions Copyright 2011-2016 ForgeRock AS. * Portions Copyright 2026 3A Systems, LLC. */ package org.opends.server.replication.protocol; import static org.opends.server.util.StaticUtils.*; import java.io.BufferedInputStream; import java.io.BufferedOutputStream; import java.io.Closeable; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.net.Socket; import java.net.SocketException; import java.util.concurrent.CountDownLatch; import java.util.concurrent.LinkedBlockingQueue; import java.util.concurrent.TimeUnit; import java.util.concurrent.atomic.AtomicBoolean; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; import java.util.zip.DataFormatException; import javax.net.ssl.SSLSocket; import org.forgerock.i18n.LocalizableMessage; import org.forgerock.i18n.slf4j.LocalizedLogger; import org.opends.server.api.DirectoryThread; import org.opends.server.types.HostPort; import org.opends.server.util.StaticUtils; /** * This class defines a replication session using TLS. */ public final class Session extends DirectoryThread implements Closeable { private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass(); /** * How long a close spends sending what the publisher thread left queued in {@code sendQueue}, * in milliseconds. *

* The same 5 s as {@code DSRSShutdownSync.REPLICA_OFFLINE_GRACE_PERIOD}, which is how long a * shutdown is already willing to wait for one of these messages - the announcement that a * replica went offline - to be forwarded. A close has no reason to wait longer for it than the * shutdown which is waiting on the close. It is a budget per close, though, not per shutdown: * a shutdown closes its sessions one after another, so it can pay this once for each peer which * is alive and not reading. */ private static final long DRAIN_BUDGET_MS = 5000; private final Socket plainSocket; private final SSLSocket secureSocket; private final InputStream plainInput; private final OutputStream plainOutput; private final byte[] rcvLengthBuf = new byte[8]; private final String readableRemoteAddress; private final HostPort remoteAddress; private final HostPort localUrl; /** The time the last message published to this session. */ private volatile long lastPublishTime; /** The time the last message was received on this session. */ private volatile long lastReceiveTime; /** * Close and error guarded by stateLock: use a different lock to publish since * publishing can block, and we don't want to block while closing failed * connections. */ private final Object stateLock = new Object(); private volatile boolean closeInitiated; private Throwable sessionError; /** * Publish guarded by publishLock: use a full lock here so that we can * optionally publish StopMsg during close. */ private final Lock publishLock = new ReentrantLock(); /** * These do not need synchronization because they are only modified during the * initial single threaded handshake. */ private short protocolVersion = ProtocolVersion.getCurrentVersion(); /** Initially encrypted. */ private boolean isEncrypted = true; /** * Use a buffered input stream to avoid too many system calls. */ private BufferedInputStream input; /** * Use a buffered output stream in order to combine message length and content * into a single TCP packet if possible. */ private BufferedOutputStream output; private final LinkedBlockingQueue sendQueue = new LinkedBlockingQueue<>(4000); private AtomicBoolean isRunning = new AtomicBoolean(false); private final CountDownLatch latch = new CountDownLatch(1); /** * How many buffers the publisher thread took off {@code sendQueue} and failed to write. After a * failed write that thread goes on taking the queue until the session is closed, and every * write after the first fails as well, so these are messages the peer was not told about just * as much as what is still queued - see {@link #close()}. */ private final AtomicInteger publisherFailedWrites = new AtomicInteger(); /** * Creates a new Session. * * @param socket * The regular Socket on which the SocketSession will be based. * @param secureSocket * The secure Socket on which the SocketSession will be based. * @throws IOException * When an IException happens on the socket. */ public Session(final Socket socket, final SSLSocket secureSocket) throws IOException { super("Replication Session from "+ socket.getLocalSocketAddress() + " to " + socket.getRemoteSocketAddress()); if (logger.isTraceEnabled()) { logger.trace( "Creating Session from %s to %s in %s", socket.getLocalSocketAddress(), socket.getRemoteSocketAddress(), stackTraceToSingleLineString(new Exception())); } this.plainSocket = socket; this.secureSocket = secureSocket; this.plainInput = plainSocket.getInputStream(); this.plainOutput = plainSocket.getOutputStream(); this.input = new BufferedInputStream(secureSocket.getInputStream()); this.output = new BufferedOutputStream(secureSocket.getOutputStream()); this.readableRemoteAddress = plainSocket.getRemoteSocketAddress().toString(); this.remoteAddress = new HostPort(plainSocket.getInetAddress().getHostAddress(), plainSocket.getPort()); this.localUrl = new HostPort(plainSocket.getLocalAddress().getHostName(), plainSocket.getLocalPort()); } /** * This method is called when the session with the remote must be closed. * This object won't be used anymore after this method is called. *

* A message which was published on this session but which its publisher thread had not sent yet * is sent here rather than dropped, within the budget of {@link #DRAIN_BUDGET_MS}. See {@link * #sendWhatThePublisherLeftQueued()}. */ @Override public void close() { Throwable localSessionError; synchronized (stateLock) { if (closeInitiated) { return; } localSessionError = sessionError; closeInitiated = true; } try { interrupt(); join(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } /* * Re-read the error rather than answer with the snapshot taken before the join: a publisher * whose send() failed while this thread was joining it recorded the error there, and what * follows - the drain and the StopMsg - is what must not be written to a socket which has * already failed. Reading it before the join left both writing to one, the drain naming its * own failure rather than the one the publisher had recorded. */ synchronized (stateLock) { localSessionError = sessionError; } // Perform close outside of critical section. if (logger.isTraceEnabled()) { if (localSessionError == null) { logger.trace( "Closing Session from %s to %s in %s", plainSocket.getLocalSocketAddress(), plainSocket.getRemoteSocketAddress(), stackTraceToSingleLineString(new Exception())); } else { logger.traceException(localSessionError, "Aborting Session from %s to %s in %s due to the following error", plainSocket.getLocalSocketAddress(), plainSocket.getRemoteSocketAddress(), stackTraceToSingleLineString(new Exception())); } } if (localSessionError != null) { /* * Nothing more is written to a session which already failed, neither what the publisher left * queued nor the StopMsg: writing more to it cannot work. What it gives up on is still * reported: what is left in the queue, and what the publisher took off it and failed to * write - the write which failed first, and every one it went on failing until this close * stopped it, which the join above makes complete. It is done without taking publishLock, * so that a thread blocked in a write of this socket is released by the close of the * sockets below rather than waited for. */ isRunning.set(false); final int notSent = publisherFailedWrites.get() + takeWhatIsLeftQueued(); if (notSent > 0) { reportQueueNotSent(notSent, "the session had already failed: " + localSessionError); } StaticUtils.close(plainSocket, secureSocket); return; } /* * The publisher thread has stopped and what it had not sent is still in the queue. Send it, * rather than let the close drop it: nothing publishes these again, and the StopMsg which * follows leaves the peer reading an orderly close with no sign that anything was missing. * * This thread is not the only one which can write the socket here - a ServerWriter or a * HeartbeatThread outlives this close. It is this thread, not run(), which takes the session * off the queueing branch of publish(), and it does so under publishLock and keeps the lock * across the queue and the StopMsg: until then a publish() concurrent with the close takes * the queueing branch, and from then on it takes the synchronous branch and waits for the * lock. Either way nothing newer is written between two of the drained messages - see * sendWhatThePublisherLeftQueued() below. */ publishLock.lock(); try { isRunning.set(false); sendWhatThePublisherLeftQueued(); /* * Re-read again: a write of the drain which failed recorded its error, and the StopMsg must * not be written to a socket which has already failed either. */ synchronized (stateLock) { localSessionError = sessionError; } // V4 protocol introduces a StopMsg to properly end communications. if (localSessionError == null && protocolVersion >= ProtocolVersion.REPLICATION_PROTOCOL_V4) { try { publish(new StopMsg()); } catch (final IOException ignored) { // Ignore errors on close. } } } finally { publishLock.unlock(); } StaticUtils.close(plainSocket, secureSocket); } /** * Sends the buffers the publisher thread had not sent when it stopped, so that a close of the * session does not drop them. *

* Called from {@link #close()} once the publisher has been joined, with {@code publishLock} * held. A queued message is already encoded for this peer's protocol version - {@link * #publish(ReplicationMsg)} did that before queueing it - so there is nothing to decide here * beyond how long to keep trying. *

* The whole queue goes out under {@code publishLock}, because the publisher is not the only * thread which writes this socket: a {@code ServerWriter} is joined only after the close which * gets here (ServerHandler.shutdown() closes the session before joining it, and ServerReader's * finally closes it before stopping the handler), and a {@code HeartbeatThread} is shut down * after it too. Their {@code publish()} takes the synchronous branch once the session is off * the queueing one, so without the lock a newer message could be written between two of these * older ones - which a peer replication server answers by dropping the older ones at debug * level, its log file refusing a record which would break its key ordering. The close takes * the session off the queueing branch under the same lock it holds here, so such a * {@code publish()} either queues ahead of that and is drained here, or waits for the drain and * lands after it, or returns at the door, having seen the close. One which read the close as * not yet begun can still be descheduled before its buffer is queued and queue it after the * last poll below; it checks for that once it has, and takes the buffer back and reports it - * see {@link #publish(ReplicationMsg)}. The wait for the lock itself is not part of the budget * below, no more than it is for the {@code StopMsg} which follows. *

* The budget bounds how many messages a close spends on a peer which is reading slowly. A peer * which is reading pays none of it; a peer which answers with a reset pays one failed write. It * is checked between messages, so a single write which blocks past the budget still runs to * completion - for a peer which has stopped reading, or which vanished without a reset, that is * for as long as TCP keeps the connection alive: bounding it needs a non-blocking socket, which * this session is not. The budget is per close, and a shutdown closes its sessions one after * another - ReplicationServerDomain.stopAllServers() stops each handler in turn on one thread - * so a domain with several peers which are alive and not reading pays it once per such peer. * On the road which does not shut the whole server down the wait is paid under the lock of the * replication domain - ReplicationServerDomain.stopServer() holds it across the handler * shutdown which closes this session - where a handshake meanwhile waiting on that lock times * out and the broker retries. What is given up on is reported rather than dropped in silence, * that being the part of this which cost the most to diagnose. */ private void sendWhatThePublisherLeftQueued() { final long deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_BUDGET_MS); byte[] buffer; while ((buffer = sendQueue.poll()) != null) { if (System.nanoTime() - deadline >= 0) { reportQueueNotSent(1 + takeWhatIsLeftQueued(), "the peer did not read them within " + DRAIN_BUDGET_MS + " ms"); return; } try { send(buffer); } catch (final IOException e) { /* * send() has recorded the error; the rest of the queue cannot go out either. The * exception is named rather than traced: this is reached whenever a peer which has * announced it is leaving closes before the drain reaches it, where what the write * failed with is the whole of what a reader of the log needs - a directory server * re-reads these from the changelog when it reconnects, a replication server does not. */ reportQueueNotSent(1 + takeWhatIsLeftQueued(), "the write failed with " + e.getClass().getName() + ": " + e.getMessage()); return; } } } /** * Empties the queue a close gives up on, and answers how many buffers it held. Taking them * rather than counting them is what keeps a {@code publish()} which queued a buffer late from * reporting it a second time: it reports only a buffer it still finds in the queue. */ private int takeWhatIsLeftQueued() { int taken = 0; while (sendQueue.poll() != null) { taken++; } return taken; } /** Says which messages a close could not hand to the peer, and why. */ private void reportQueueNotSent(final int count, final String reason) { logger.warn(LocalizableMessage.raw( "The replication session %s was closed with %d message(s) which had been published on it " + "but not yet sent, and the peer was not told about them: %s", getName(), count, reason)); } /** * This methods allows to determine if the session close was initiated * on this Session. * * @return A boolean allowing to determine if the session close was initiated * on this Session. */ public boolean closeInitiated() { synchronized (stateLock) { return closeInitiated; } } /** * Gets the time the last replication message was published on this * session. * @return The timestamp in milliseconds of the last message published. */ public long getLastPublishTime() { return lastPublishTime; } /** * Gets the time the last replication message was received on this * session. * @return The timestamp in milliseconds of the last message received. */ public long getLastReceiveTime() { if (lastReceiveTime == 0) { return System.currentTimeMillis(); } return lastReceiveTime; } /** * Retrieve the local URL in the form host:port. * * @return The local URL. */ public HostPort getLocalUrl() { return localUrl; } /** * Retrieve the human readable address of the remote server. * * @return The human readable address of the remote server. */ public String getReadableRemoteAddress() { return readableRemoteAddress; } /** * Retrieve the IP address and port of the remote server. * * @return The IP address and port of the remote server. */ public HostPort getRemoteAddress() { return remoteAddress; } /** * Determine whether the session is using a security layer. * @return true if the connection is encrypted, false otherwise. */ public boolean isEncrypted() { return isEncrypted; } /** * Sends a replication message to the remote peer. * * @param msg * The message to be sent. * @throws IOException * If an IO error occurred. */ public void publish(final ReplicationMsg msg) throws IOException { final byte[] buffer = msg.getBytes(protocolVersion); if (buffer == null) { // skip anything that cannot be encoded for this peer. return; } if (isRunning.get()) { while (!closeInitiated) { try { // Avoid blocking forever so that we can check for session closure. if (sendQueue.offer(buffer, 100, TimeUnit.MILLISECONDS)) { if (!isRunning.get()) { takeBackWhatWasQueuedTooLate(buffer); } return; } } catch (final InterruptedException e) { setSessionError(e); throw new IOException(e.getMessage()); } } } else { send(buffer); } } /** * Takes a buffer back out of the queue if nothing is going to send it, and reports it. *

* {@code publish()} reads the close as not yet begun and queues the buffer after that, with no * lock in between: descheduled there, it can queue the buffer after a close has stopped the * publisher thread and drained the queue, and nothing would then send it or say so. Once the * session is off the queueing branch, the lock here is the one the close holds while it takes * the session off that branch and drains the queue, so what is still in the queue after it is * what nothing sends. A buffer queued before the session came off the queueing branch is left * to the drain - it cannot have seen the flag cleared - and a buffer the drain or the close * already took is not found here, so nothing is reported twice. */ private void takeBackWhatWasQueuedTooLate(final byte[] buffer) { publishLock.lock(); try { if (sendQueue.remove(buffer)) { reportQueueNotSent(1, "it was queued after the publisher of the session had stopped"); } } finally { publishLock.unlock(); } } /** Sends a replication message already encoded to the socket. * * @param buffer * the encoded buffer * @throws IOException if the message could not be sent */ private void send(final byte[] buffer) throws IOException { final String str = String.format("%08x", buffer.length); final byte[] sendLengthBuf = str.getBytes(); publishLock.lock(); try { /* * The buffered output stream ensures that the message is usually sent as * a single TCP packet. */ output.write(sendLengthBuf); output.write(buffer); output.flush(); } catch (final IOException e) { setSessionError(e); throw e; } finally { publishLock.unlock(); } lastPublishTime = System.currentTimeMillis(); } /** * Attempt to receive a ReplicationMsg. * This method should block the calling thread until a * ReplicationMsg is available or until an error condition. * * This method can only be called by a single thread and therefore does not * need to implement any replication. * * @return The ReplicationMsg that was received. * @throws IOException When error happened during IO process. * @throws DataFormatException When the data received is not formatted as a * ReplicationMsg. * @throws NotSupportedOldVersionPDUException If the received PDU is part of * an old protocol version and we do not support it. */ public ReplicationMsg receive() throws IOException, DataFormatException, NotSupportedOldVersionPDUException { try { /* * Let's start the stop-watch before waiting on read for the heartbeat * check to be operational. */ lastReceiveTime = System.currentTimeMillis(); // Read the first 8 bytes containing the packet length. read(rcvLengthBuf); final int totalLength = Integer.parseInt(new String(rcvLengthBuf), 16); try { final byte[] buffer = new byte[totalLength]; read(buffer); /* * We do not want the heartbeat to close the session when we are * processing a message even a time consuming one. */ lastReceiveTime = 0; return ReplicationMsg.generateMsg(buffer, protocolVersion); } catch (final OutOfMemoryError e) { throw new IOException("Packet too large, can't allocate " + totalLength + " bytes."); } } catch (final IOException | DataFormatException | NotSupportedOldVersionPDUException | RuntimeException e) { setSessionError(e); throw e; } } private void read(byte[] buffer) throws IOException { final int totalLength = buffer.length; int length = 0; while (length < totalLength) { final int read = input.read(buffer, length, totalLength - length); if (read == -1) { lastReceiveTime = 0; throw new IOException("no more data"); } length += read; } } /** * This method is called at the establishment of the session and can * be used to record the version of the protocol that is currently used. * * @param version The version of the protocol that is currently used. */ public void setProtocolVersion(final short version) { protocolVersion = version; } /** * Returns the version of the protocol that is currently used. * * @return The version of the protocol that is currently used. */ public short getProtocolVersion() { return protocolVersion; } /** * Set a timeout value. * With this option set to a non-zero value, calls to the receive() method * block for only this amount of time after which a * java.net.SocketTimeoutException is raised. * The Broker is valid and usable even after such an Exception is raised. * * @param timeout the specified timeout, in milliseconds. * @throws SocketException if there is an error in the underlying protocol, * such as a TCP error. */ public void setSoTimeout(final int timeout) throws SocketException { plainSocket.setSoTimeout(timeout); } /** * Stop using the security layer, if there is any. */ public void stopEncryption() { /* * The secure socket has been configured not to auto close the underlying * plain socket. We should close it here and properly tear down the SSL * session, but this is not compatible with the existing protocol. */ if (false) { StaticUtils.close(secureSocket); } input = new BufferedInputStream(plainInput); output = new BufferedOutputStream(plainOutput); isEncrypted = false; } private void setSessionError(final Exception e) { synchronized (stateLock) { if (sessionError == null) { sessionError = e; } } } /** * Run method for the Session. * Loops waiting for buffers from the queue and sends them when available. */ @Override public void run() { synchronized (stateLock) { /* * A close which came before the start has already run, and it is not coming back to clear * the flag - nor is the end of this method, which leaves that to the close. Set, the flag * would keep every later publish() on the queueing branch, which returns at once on a * closed session as if the message had been queued; unset, publish() stays on the * synchronous branch, which fails on the closed socket. */ if (!closeInitiated) { isRunning.set(true); } } latch.countDown(); if (logger.isTraceEnabled()) { logger.trace(getName() + " starting."); } boolean needClosing = false; while (!closeInitiated) { byte[] buffer; try { buffer = sendQueue.take(); } catch (InterruptedException ie) { break; } try { send(buffer); } catch (IOException e) { setSessionError(e); publisherFailedWrites.incrementAndGet(); needClosing = true; } } /* * A close clears the flag itself, under publishLock, once it has joined this thread - see * close(). Clearing it here would open a window between the end of this thread and the drain * of the close, in which a publish() takes the synchronous branch and writes a newer message * ahead of the queue. Only a loop which ended without a close - an interrupt from elsewhere - * clears it here, so that publish() does not go on queueing onto a queue nobody sends. */ if (!closeInitiated) { isRunning.set(false); } if (needClosing) { close(); } if (logger.isTraceEnabled()) { logger.trace(getName() + " stopped."); } } /** * This method can be called to wait until the session thread is * properly started. * @throws InterruptedException when interrupted */ public void waitForStartup() throws InterruptedException { latch.await(); } }