mirror of
https://github.com/spring-projects/spring-boot.git
synced 2024-09-03 04:26:12 +08:00
Merge branch '2.7.x' into 3.0.x
Closes gh-36818
This commit is contained in:
commit
95b0b62b0a
@ -1,219 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2022 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.io.Closeable;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.ConnectException;
|
|
||||||
import java.net.MalformedURLException;
|
|
||||||
import java.net.URI;
|
|
||||||
import java.net.URISyntaxException;
|
|
||||||
import java.net.URL;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
import java.util.concurrent.Executors;
|
|
||||||
import java.util.concurrent.ThreadFactory;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import org.apache.commons.logging.Log;
|
|
||||||
import org.apache.commons.logging.LogFactory;
|
|
||||||
|
|
||||||
import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayload;
|
|
||||||
import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayloadForwarder;
|
|
||||||
import org.springframework.core.log.LogMessage;
|
|
||||||
import org.springframework.http.HttpMethod;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.client.ClientHttpRequest;
|
|
||||||
import org.springframework.http.client.ClientHttpRequestFactory;
|
|
||||||
import org.springframework.http.client.ClientHttpResponse;
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link TunnelConnection} implementation that uses HTTP to transfer data.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @author Rob Winch
|
|
||||||
* @author Andy Wilkinson
|
|
||||||
* @since 1.3.0
|
|
||||||
* @see TunnelClient
|
|
||||||
* @see org.springframework.boot.devtools.tunnel.server.HttpTunnelServer
|
|
||||||
*/
|
|
||||||
public class HttpTunnelConnection implements TunnelConnection {
|
|
||||||
|
|
||||||
private static final Log logger = LogFactory.getLog(HttpTunnelConnection.class);
|
|
||||||
|
|
||||||
private final URI uri;
|
|
||||||
|
|
||||||
private final ClientHttpRequestFactory requestFactory;
|
|
||||||
|
|
||||||
private final Executor executor;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new {@link HttpTunnelConnection} instance.
|
|
||||||
* @param url the URL to connect to
|
|
||||||
* @param requestFactory the HTTP request factory
|
|
||||||
*/
|
|
||||||
public HttpTunnelConnection(String url, ClientHttpRequestFactory requestFactory) {
|
|
||||||
this(url, requestFactory, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new {@link HttpTunnelConnection} instance.
|
|
||||||
* @param url the URL to connect to
|
|
||||||
* @param requestFactory the HTTP request factory
|
|
||||||
* @param executor the executor used to handle connections
|
|
||||||
*/
|
|
||||||
protected HttpTunnelConnection(String url, ClientHttpRequestFactory requestFactory, Executor executor) {
|
|
||||||
Assert.hasLength(url, "URL must not be empty");
|
|
||||||
Assert.notNull(requestFactory, "RequestFactory must not be null");
|
|
||||||
try {
|
|
||||||
this.uri = new URL(url).toURI();
|
|
||||||
}
|
|
||||||
catch (URISyntaxException | MalformedURLException ex) {
|
|
||||||
throw new IllegalArgumentException("Malformed URL '" + url + "'");
|
|
||||||
}
|
|
||||||
this.requestFactory = requestFactory;
|
|
||||||
this.executor = (executor != null) ? executor : Executors.newCachedThreadPool(new TunnelThreadFactory());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public TunnelChannel open(WritableByteChannel incomingChannel, Closeable closeable) throws Exception {
|
|
||||||
logger.trace(LogMessage.format("Opening HTTP tunnel to %s", this.uri));
|
|
||||||
return new TunnelChannel(incomingChannel, closeable);
|
|
||||||
}
|
|
||||||
|
|
||||||
protected final ClientHttpRequest createRequest(boolean hasPayload) throws IOException {
|
|
||||||
HttpMethod method = hasPayload ? HttpMethod.POST : HttpMethod.GET;
|
|
||||||
return this.requestFactory.createRequest(this.uri, method);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A {@link WritableByteChannel} used to transfer traffic.
|
|
||||||
*/
|
|
||||||
protected class TunnelChannel implements WritableByteChannel {
|
|
||||||
|
|
||||||
private final HttpTunnelPayloadForwarder forwarder;
|
|
||||||
|
|
||||||
private final Closeable closeable;
|
|
||||||
|
|
||||||
private boolean open = true;
|
|
||||||
|
|
||||||
private final AtomicLong requestSeq = new AtomicLong();
|
|
||||||
|
|
||||||
public TunnelChannel(WritableByteChannel incomingChannel, Closeable closeable) {
|
|
||||||
this.forwarder = new HttpTunnelPayloadForwarder(incomingChannel);
|
|
||||||
this.closeable = closeable;
|
|
||||||
openNewConnection(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOpen() {
|
|
||||||
return this.open;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws IOException {
|
|
||||||
if (this.open) {
|
|
||||||
this.open = false;
|
|
||||||
this.closeable.close();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int write(ByteBuffer src) throws IOException {
|
|
||||||
int size = src.remaining();
|
|
||||||
if (size > 0) {
|
|
||||||
openNewConnection(new HttpTunnelPayload(this.requestSeq.incrementAndGet(), src));
|
|
||||||
}
|
|
||||||
return size;
|
|
||||||
}
|
|
||||||
|
|
||||||
private void openNewConnection(HttpTunnelPayload payload) {
|
|
||||||
HttpTunnelConnection.this.executor.execute(new Runnable() {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
sendAndReceive(payload);
|
|
||||||
}
|
|
||||||
catch (IOException ex) {
|
|
||||||
if (ex instanceof ConnectException) {
|
|
||||||
logger.warn(LogMessage.format("Failed to connect to remote application at %s",
|
|
||||||
HttpTunnelConnection.this.uri));
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
logger.trace("Unexpected connection error", ex);
|
|
||||||
}
|
|
||||||
closeQuietly();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void closeQuietly() {
|
|
||||||
try {
|
|
||||||
close();
|
|
||||||
}
|
|
||||||
catch (IOException ex) {
|
|
||||||
// Ignore
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
private void sendAndReceive(HttpTunnelPayload payload) throws IOException {
|
|
||||||
ClientHttpRequest request = createRequest(payload != null);
|
|
||||||
if (payload != null) {
|
|
||||||
payload.logIncoming();
|
|
||||||
payload.assignTo(request);
|
|
||||||
}
|
|
||||||
handleResponse(request.execute());
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleResponse(ClientHttpResponse response) throws IOException {
|
|
||||||
if (response.getStatusCode() == HttpStatus.GONE) {
|
|
||||||
close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (response.getStatusCode() == HttpStatus.OK) {
|
|
||||||
HttpTunnelPayload payload = HttpTunnelPayload.get(response);
|
|
||||||
if (payload != null) {
|
|
||||||
this.forwarder.forward(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (response.getStatusCode() != HttpStatus.TOO_MANY_REQUESTS) {
|
|
||||||
openNewConnection(null);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link ThreadFactory} used to create the tunnel thread.
|
|
||||||
*/
|
|
||||||
private static class TunnelThreadFactory implements ThreadFactory {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public Thread newThread(Runnable runnable) {
|
|
||||||
Thread thread = new Thread(runnable, "HTTP Tunnel Connection");
|
|
||||||
thread.setDaemon(true);
|
|
||||||
return thread;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,223 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.io.Closeable;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.ServerSocket;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.AsynchronousCloseException;
|
|
||||||
import java.nio.channels.ServerSocketChannel;
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
|
|
||||||
import org.apache.commons.logging.Log;
|
|
||||||
import org.apache.commons.logging.LogFactory;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.SmartInitializingSingleton;
|
|
||||||
import org.springframework.core.log.LogMessage;
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The client side component of a socket tunnel. Starts a {@link ServerSocket} of the
|
|
||||||
* specified port for local clients to connect to.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @author Andy Wilkinson
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public class TunnelClient implements SmartInitializingSingleton {
|
|
||||||
|
|
||||||
private static final int BUFFER_SIZE = 1024 * 100;
|
|
||||||
|
|
||||||
private static final Log logger = LogFactory.getLog(TunnelClient.class);
|
|
||||||
|
|
||||||
private final TunnelClientListeners listeners = new TunnelClientListeners();
|
|
||||||
|
|
||||||
private final Object monitor = new Object();
|
|
||||||
|
|
||||||
private final int listenPort;
|
|
||||||
|
|
||||||
private final TunnelConnection tunnelConnection;
|
|
||||||
|
|
||||||
private ServerThread serverThread;
|
|
||||||
|
|
||||||
public TunnelClient(int listenPort, TunnelConnection tunnelConnection) {
|
|
||||||
Assert.isTrue(listenPort >= 0, "ListenPort must be greater than or equal to 0");
|
|
||||||
Assert.notNull(tunnelConnection, "TunnelConnection must not be null");
|
|
||||||
this.listenPort = listenPort;
|
|
||||||
this.tunnelConnection = tunnelConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void afterSingletonsInstantiated() {
|
|
||||||
synchronized (this.monitor) {
|
|
||||||
if (this.serverThread == null) {
|
|
||||||
try {
|
|
||||||
start();
|
|
||||||
}
|
|
||||||
catch (IOException ex) {
|
|
||||||
throw new IllegalStateException(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start the client and accept incoming connections.
|
|
||||||
* @return the port on which the client is listening
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public int start() throws IOException {
|
|
||||||
synchronized (this.monitor) {
|
|
||||||
Assert.state(this.serverThread == null, "Server already started");
|
|
||||||
ServerSocketChannel serverSocketChannel = ServerSocketChannel.open();
|
|
||||||
serverSocketChannel.socket().bind(new InetSocketAddress(this.listenPort));
|
|
||||||
int port = serverSocketChannel.socket().getLocalPort();
|
|
||||||
logger.trace(LogMessage.format("Listening for TCP traffic to tunnel on port %s", port));
|
|
||||||
this.serverThread = new ServerThread(serverSocketChannel);
|
|
||||||
this.serverThread.start();
|
|
||||||
return port;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Stop the client, disconnecting any servers.
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void stop() throws IOException {
|
|
||||||
synchronized (this.monitor) {
|
|
||||||
if (this.serverThread != null) {
|
|
||||||
this.serverThread.close();
|
|
||||||
try {
|
|
||||||
this.serverThread.join(2000);
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
this.serverThread = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected final ServerThread getServerThread() {
|
|
||||||
synchronized (this.monitor) {
|
|
||||||
return this.serverThread;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public void addListener(TunnelClientListener listener) {
|
|
||||||
this.listeners.addListener(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void removeListener(TunnelClientListener listener) {
|
|
||||||
this.listeners.removeListener(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The main server thread.
|
|
||||||
*/
|
|
||||||
protected class ServerThread extends Thread {
|
|
||||||
|
|
||||||
private final ServerSocketChannel serverSocketChannel;
|
|
||||||
|
|
||||||
private boolean acceptConnections = true;
|
|
||||||
|
|
||||||
public ServerThread(ServerSocketChannel serverSocketChannel) {
|
|
||||||
this.serverSocketChannel = serverSocketChannel;
|
|
||||||
setName("Tunnel Server");
|
|
||||||
setDaemon(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
public void close() throws IOException {
|
|
||||||
logger.trace(LogMessage.format("Closing tunnel client on port %s",
|
|
||||||
this.serverSocketChannel.socket().getLocalPort()));
|
|
||||||
this.serverSocketChannel.close();
|
|
||||||
this.acceptConnections = false;
|
|
||||||
interrupt();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
while (this.acceptConnections) {
|
|
||||||
try (SocketChannel socket = this.serverSocketChannel.accept()) {
|
|
||||||
handleConnection(socket);
|
|
||||||
}
|
|
||||||
catch (AsynchronousCloseException ex) {
|
|
||||||
// Connection has been closed. Keep the server running
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
logger.trace("Unexpected exception from tunnel client", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void handleConnection(SocketChannel socketChannel) throws Exception {
|
|
||||||
Closeable closeable = new SocketCloseable(socketChannel);
|
|
||||||
TunnelClient.this.listeners.fireOpenEvent(socketChannel);
|
|
||||||
try (WritableByteChannel outputChannel = TunnelClient.this.tunnelConnection.open(socketChannel,
|
|
||||||
closeable)) {
|
|
||||||
logger.trace(
|
|
||||||
"Accepted connection to tunnel client from " + socketChannel.socket().getRemoteSocketAddress());
|
|
||||||
while (true) {
|
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
|
|
||||||
int amountRead = socketChannel.read(buffer);
|
|
||||||
if (amountRead == -1) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (amountRead > 0) {
|
|
||||||
buffer.flip();
|
|
||||||
outputChannel.write(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
protected void stopAcceptingConnections() {
|
|
||||||
this.acceptConnections = false;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link Closeable} used to close a {@link SocketChannel} and fire an event.
|
|
||||||
*/
|
|
||||||
private class SocketCloseable implements Closeable {
|
|
||||||
|
|
||||||
private final SocketChannel socketChannel;
|
|
||||||
|
|
||||||
private boolean closed = false;
|
|
||||||
|
|
||||||
SocketCloseable(SocketChannel socketChannel) {
|
|
||||||
this.socketChannel = socketChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws IOException {
|
|
||||||
if (!this.closed) {
|
|
||||||
this.socketChannel.close();
|
|
||||||
TunnelClient.this.listeners.fireCloseEvent(this.socketChannel);
|
|
||||||
this.closed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Listener that can be used to receive {@link TunnelClient} events.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public interface TunnelClientListener {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a socket channel is opened.
|
|
||||||
* @param socket the socket channel
|
|
||||||
*/
|
|
||||||
void onOpen(SocketChannel socket);
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a socket channel is closed.
|
|
||||||
* @param socket the socket channel
|
|
||||||
*/
|
|
||||||
void onClose(SocketChannel socket);
|
|
||||||
|
|
||||||
}
|
|
@ -1,56 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
import java.util.List;
|
|
||||||
import java.util.concurrent.CopyOnWriteArrayList;
|
|
||||||
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A collection of {@link TunnelClientListener}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class TunnelClientListeners {
|
|
||||||
|
|
||||||
private final List<TunnelClientListener> listeners = new CopyOnWriteArrayList<>();
|
|
||||||
|
|
||||||
void addListener(TunnelClientListener listener) {
|
|
||||||
Assert.notNull(listener, "Listener must not be null");
|
|
||||||
this.listeners.add(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
void removeListener(TunnelClientListener listener) {
|
|
||||||
Assert.notNull(listener, "Listener must not be null");
|
|
||||||
this.listeners.remove(listener);
|
|
||||||
}
|
|
||||||
|
|
||||||
void fireOpenEvent(SocketChannel socket) {
|
|
||||||
for (TunnelClientListener listener : this.listeners) {
|
|
||||||
listener.onOpen(socket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void fireCloseEvent(SocketChannel socket) {
|
|
||||||
for (TunnelClientListener listener : this.listeners) {
|
|
||||||
listener.onClose(socket);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,42 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.io.Closeable;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Interface used to manage socket tunnel connections.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TunnelConnection {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open the tunnel connection.
|
|
||||||
* @param incomingChannel a {@link WritableByteChannel} that should be used to write
|
|
||||||
* any incoming data received from the remote server
|
|
||||||
* @param closeable a closeable to call when the channel is closed
|
|
||||||
* @return a {@link WritableByteChannel} that should be used to send any outgoing data
|
|
||||||
* destined for the remote server
|
|
||||||
* @throws Exception in case of errors
|
|
||||||
*/
|
|
||||||
WritableByteChannel open(WritableByteChannel incomingChannel, Closeable closeable) throws Exception;
|
|
||||||
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Client side TCP tunnel support.
|
|
||||||
*/
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
@ -1,22 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Provides support for tunneling TCP traffic over HTTP. Tunneling is primarily designed
|
|
||||||
* for the Java Debug Wire Protocol (JDWP) and as such only expects a single connection
|
|
||||||
* and isn't particularly worried about resource usage.
|
|
||||||
*/
|
|
||||||
package org.springframework.boot.devtools.tunnel;
|
|
@ -1,180 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.payload;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.io.InterruptedIOException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.ReadableByteChannel;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
import java.util.HexFormat;
|
|
||||||
|
|
||||||
import org.apache.commons.logging.Log;
|
|
||||||
import org.apache.commons.logging.LogFactory;
|
|
||||||
|
|
||||||
import org.springframework.http.HttpHeaders;
|
|
||||||
import org.springframework.http.HttpInputMessage;
|
|
||||||
import org.springframework.http.HttpOutputMessage;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
import org.springframework.util.StringUtils;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulates a payload data sent over a HTTP tunnel.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public class HttpTunnelPayload {
|
|
||||||
|
|
||||||
private static final String SEQ_HEADER = "x-seq";
|
|
||||||
|
|
||||||
private static final int BUFFER_SIZE = 1024 * 100;
|
|
||||||
|
|
||||||
private static final HexFormat HEX_FORMAT = HexFormat.of().withUpperCase();
|
|
||||||
|
|
||||||
private static final Log logger = LogFactory.getLog(HttpTunnelPayload.class);
|
|
||||||
|
|
||||||
private final long sequence;
|
|
||||||
|
|
||||||
private final ByteBuffer data;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new {@link HttpTunnelPayload} instance.
|
|
||||||
* @param sequence the sequence number of the payload
|
|
||||||
* @param data the payload data
|
|
||||||
*/
|
|
||||||
public HttpTunnelPayload(long sequence, ByteBuffer data) {
|
|
||||||
Assert.isTrue(sequence > 0, "Sequence must be positive");
|
|
||||||
Assert.notNull(data, "Data must not be null");
|
|
||||||
this.sequence = sequence;
|
|
||||||
this.data = data;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the sequence number of the payload.
|
|
||||||
* @return the sequence
|
|
||||||
*/
|
|
||||||
public long getSequence() {
|
|
||||||
return this.sequence;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Assign this payload to the given {@link HttpOutputMessage}.
|
|
||||||
* @param message the message to assign this payload to
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void assignTo(HttpOutputMessage message) throws IOException {
|
|
||||||
Assert.notNull(message, "Message must not be null");
|
|
||||||
HttpHeaders headers = message.getHeaders();
|
|
||||||
headers.setContentLength(this.data.remaining());
|
|
||||||
headers.add(SEQ_HEADER, Long.toString(getSequence()));
|
|
||||||
headers.setContentType(MediaType.APPLICATION_OCTET_STREAM);
|
|
||||||
try (WritableByteChannel body = Channels.newChannel(message.getBody())) {
|
|
||||||
while (this.data.hasRemaining()) {
|
|
||||||
body.write(this.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Write the content of this payload to the given target channel.
|
|
||||||
* @param channel the channel to write to
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void writeTo(WritableByteChannel channel) throws IOException {
|
|
||||||
Assert.notNull(channel, "Channel must not be null");
|
|
||||||
while (this.data.hasRemaining()) {
|
|
||||||
channel.write(this.data);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the {@link HttpTunnelPayload} for the given message or {@code null} if there
|
|
||||||
* is no payload.
|
|
||||||
* @param message the HTTP message
|
|
||||||
* @return the payload or {@code null}
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public static HttpTunnelPayload get(HttpInputMessage message) throws IOException {
|
|
||||||
long length = message.getHeaders().getContentLength();
|
|
||||||
if (length <= 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
String seqHeader = message.getHeaders().getFirst(SEQ_HEADER);
|
|
||||||
Assert.state(StringUtils.hasLength(seqHeader), "Missing sequence header");
|
|
||||||
try (ReadableByteChannel body = Channels.newChannel(message.getBody())) {
|
|
||||||
ByteBuffer payload = ByteBuffer.allocate((int) length);
|
|
||||||
while (payload.hasRemaining()) {
|
|
||||||
body.read(payload);
|
|
||||||
}
|
|
||||||
payload.flip();
|
|
||||||
return new HttpTunnelPayload(Long.parseLong(seqHeader), payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the payload data for the given source {@link ReadableByteChannel} or null if
|
|
||||||
* the channel timed out whilst reading.
|
|
||||||
* @param channel the source channel
|
|
||||||
* @return payload data or {@code null}
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public static ByteBuffer getPayloadData(ReadableByteChannel channel) throws IOException {
|
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(BUFFER_SIZE);
|
|
||||||
try {
|
|
||||||
int amountRead = channel.read(buffer);
|
|
||||||
Assert.state(amountRead != -1, "Target server connection closed");
|
|
||||||
buffer.flip();
|
|
||||||
return buffer;
|
|
||||||
}
|
|
||||||
catch (InterruptedIOException ex) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log incoming payload information at trace level to aid diagnostics.
|
|
||||||
*/
|
|
||||||
public void logIncoming() {
|
|
||||||
log("< ");
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Log incoming payload information at trace level to aid diagnostics.
|
|
||||||
*/
|
|
||||||
public void logOutgoing() {
|
|
||||||
log("> ");
|
|
||||||
}
|
|
||||||
|
|
||||||
private void log(String prefix) {
|
|
||||||
if (logger.isTraceEnabled()) {
|
|
||||||
logger.trace(prefix + toHexString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the payload as a hexadecimal string.
|
|
||||||
* @return the payload as a hex string
|
|
||||||
*/
|
|
||||||
public String toHexString() {
|
|
||||||
byte[] bytes = this.data.array();
|
|
||||||
return HEX_FORMAT.formatHex(bytes);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,72 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.payload;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
import java.util.HashMap;
|
|
||||||
import java.util.Map;
|
|
||||||
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Utility class that forwards {@link HttpTunnelPayload} instances to a destination
|
|
||||||
* channel, respecting sequence order.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public class HttpTunnelPayloadForwarder {
|
|
||||||
|
|
||||||
private static final int MAXIMUM_QUEUE_SIZE = 100;
|
|
||||||
|
|
||||||
private final Map<Long, HttpTunnelPayload> queue = new HashMap<>();
|
|
||||||
|
|
||||||
private final Object monitor = new Object();
|
|
||||||
|
|
||||||
private final WritableByteChannel targetChannel;
|
|
||||||
|
|
||||||
private long lastRequestSeq = 0;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new {@link HttpTunnelPayloadForwarder} instance.
|
|
||||||
* @param targetChannel the target channel
|
|
||||||
*/
|
|
||||||
public HttpTunnelPayloadForwarder(WritableByteChannel targetChannel) {
|
|
||||||
Assert.notNull(targetChannel, "TargetChannel must not be null");
|
|
||||||
this.targetChannel = targetChannel;
|
|
||||||
}
|
|
||||||
|
|
||||||
public void forward(HttpTunnelPayload payload) throws IOException {
|
|
||||||
synchronized (this.monitor) {
|
|
||||||
long seq = payload.getSequence();
|
|
||||||
if (this.lastRequestSeq != seq - 1) {
|
|
||||||
Assert.state(this.queue.size() < MAXIMUM_QUEUE_SIZE, "Too many messages queued");
|
|
||||||
this.queue.put(seq, payload);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
payload.logOutgoing();
|
|
||||||
payload.writeTo(this.targetChannel);
|
|
||||||
this.lastRequestSeq = seq;
|
|
||||||
HttpTunnelPayload queuedItem = this.queue.get(seq + 1);
|
|
||||||
if (queuedItem != null) {
|
|
||||||
forward(queuedItem);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2021 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Classes to deal with payloads sent over an HTTP tunnel.
|
|
||||||
*/
|
|
||||||
package org.springframework.boot.devtools.tunnel.payload;
|
|
@ -1,488 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2022 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.ConnectException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.ByteChannel;
|
|
||||||
import java.util.ArrayDeque;
|
|
||||||
import java.util.Deque;
|
|
||||||
import java.util.Iterator;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicLong;
|
|
||||||
|
|
||||||
import org.apache.commons.logging.Log;
|
|
||||||
import org.apache.commons.logging.LogFactory;
|
|
||||||
|
|
||||||
import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayload;
|
|
||||||
import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayloadForwarder;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.MediaType;
|
|
||||||
import org.springframework.http.server.ServerHttpAsyncRequestControl;
|
|
||||||
import org.springframework.http.server.ServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServerHttpResponse;
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A server that can be used to tunnel TCP traffic over HTTP. Similar in design to the
|
|
||||||
* <a href="https://xmpp.org/extensions/xep-0124.html">Bidirectional-streams Over
|
|
||||||
* Synchronous HTTP (BOSH)</a> XMPP extension protocol, the server uses long polling with
|
|
||||||
* HTTP requests held open until a response is available. A typical traffic pattern would
|
|
||||||
* be as follows:
|
|
||||||
*
|
|
||||||
* <pre>
|
|
||||||
* [ CLIENT ] [ SERVER ]
|
|
||||||
* | (a) Initial empty request |
|
|
||||||
* |------------------------------>|
|
|
||||||
* | (b) Data I |
|
|
||||||
* -->|------------------------------>|--->
|
|
||||||
* | Response I (a) |
|
|
||||||
* <--|<------------------------------|<---
|
|
||||||
* | |
|
|
||||||
* | (c) Data II |
|
|
||||||
* -->|------------------------------>|--->
|
|
||||||
* | Response II (b) |
|
|
||||||
* <--|<------------------------------|<---
|
|
||||||
* . .
|
|
||||||
* . .
|
|
||||||
* </pre>
|
|
||||||
*
|
|
||||||
* Each incoming request is held open to be used to carry the next available response. The
|
|
||||||
* server will hold at most two connections open at any given time.
|
|
||||||
* <p>
|
|
||||||
* Requests should be made using HTTP GET or POST (depending if there is a payload), with
|
|
||||||
* any payload contained in the body. The following response codes can be returned from
|
|
||||||
* the server:
|
|
||||||
* <table>
|
|
||||||
* <caption>Response Codes</caption>
|
|
||||||
* <tr>
|
|
||||||
* <th>Status</th>
|
|
||||||
* <th>Meaning</th>
|
|
||||||
* </tr>
|
|
||||||
* <tr>
|
|
||||||
* <td>200 (OK)</td>
|
|
||||||
* <td>Data payload response.</td>
|
|
||||||
* </tr>
|
|
||||||
* <tr>
|
|
||||||
* <td>204 (No Content)</td>
|
|
||||||
* <td>The long poll has timed out and the client should start a new request.</td>
|
|
||||||
* </tr>
|
|
||||||
* <tr>
|
|
||||||
* <td>429 (Too many requests)</td>
|
|
||||||
* <td>There are already enough connections open, this one can be dropped.</td>
|
|
||||||
* </tr>
|
|
||||||
* <tr>
|
|
||||||
* <td>410 (Gone)</td>
|
|
||||||
* <td>The target server has disconnected.</td>
|
|
||||||
* </tr>
|
|
||||||
* <tr>
|
|
||||||
* <td>503 (Service Unavailable)</td>
|
|
||||||
* <td>The target server is unavailable</td>
|
|
||||||
* </tr>
|
|
||||||
* </table>
|
|
||||||
* <p>
|
|
||||||
* Requests and responses that contain payloads include a {@code x-seq} header that
|
|
||||||
* contains a running sequence number (used to ensure data is applied in the correct
|
|
||||||
* order). The first request containing a payload should have a {@code x-seq} value of
|
|
||||||
* {@code 1}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @author Andy Wilkinson
|
|
||||||
* @since 1.3.0
|
|
||||||
* @see org.springframework.boot.devtools.tunnel.client.HttpTunnelConnection
|
|
||||||
*/
|
|
||||||
public class HttpTunnelServer {
|
|
||||||
|
|
||||||
private static final long DEFAULT_LONG_POLL_TIMEOUT = TimeUnit.SECONDS.toMillis(10);
|
|
||||||
|
|
||||||
private static final long DEFAULT_DISCONNECT_TIMEOUT = TimeUnit.SECONDS.toMillis(30);
|
|
||||||
|
|
||||||
private static final MediaType DISCONNECT_MEDIA_TYPE = new MediaType("application", "x-disconnect");
|
|
||||||
|
|
||||||
private static final Log logger = LogFactory.getLog(HttpTunnelServer.class);
|
|
||||||
|
|
||||||
private final TargetServerConnection serverConnection;
|
|
||||||
|
|
||||||
private int longPollTimeout = (int) DEFAULT_LONG_POLL_TIMEOUT;
|
|
||||||
|
|
||||||
private long disconnectTimeout = DEFAULT_DISCONNECT_TIMEOUT;
|
|
||||||
|
|
||||||
private volatile ServerThread serverThread;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Creates a new {@link HttpTunnelServer} instance.
|
|
||||||
* @param serverConnection the connection to the target server
|
|
||||||
*/
|
|
||||||
public HttpTunnelServer(TargetServerConnection serverConnection) {
|
|
||||||
Assert.notNull(serverConnection, "ServerConnection must not be null");
|
|
||||||
this.serverConnection = serverConnection;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming HTTP connection.
|
|
||||||
* @param request the HTTP request
|
|
||||||
* @param response the HTTP response
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
|
|
||||||
handle(new HttpConnection(request, response));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming HTTP connection.
|
|
||||||
* @param httpConnection the HTTP connection
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
protected void handle(HttpConnection httpConnection) throws IOException {
|
|
||||||
try {
|
|
||||||
getServerThread().handleIncomingHttp(httpConnection);
|
|
||||||
httpConnection.waitForResponse();
|
|
||||||
}
|
|
||||||
catch (ConnectException ex) {
|
|
||||||
httpConnection.respond(HttpStatus.GONE);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns the active server thread, creating and starting it if necessary.
|
|
||||||
* @return the {@code ServerThread} (never {@code null})
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
protected ServerThread getServerThread() throws IOException {
|
|
||||||
synchronized (this) {
|
|
||||||
if (this.serverThread == null) {
|
|
||||||
ByteChannel channel = this.serverConnection.open(this.longPollTimeout);
|
|
||||||
this.serverThread = new ServerThread(channel);
|
|
||||||
this.serverThread.start();
|
|
||||||
}
|
|
||||||
return this.serverThread;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when the server thread exits.
|
|
||||||
*/
|
|
||||||
void clearServerThread() {
|
|
||||||
synchronized (this) {
|
|
||||||
this.serverThread = null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the long poll timeout for the server.
|
|
||||||
* @param longPollTimeout the long poll timeout in milliseconds
|
|
||||||
*/
|
|
||||||
public void setLongPollTimeout(int longPollTimeout) {
|
|
||||||
Assert.isTrue(longPollTimeout > 0, "LongPollTimeout must be a positive value");
|
|
||||||
this.longPollTimeout = longPollTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set the maximum amount of time to wait for a client before closing the connection.
|
|
||||||
* @param disconnectTimeout the disconnect timeout in milliseconds
|
|
||||||
*/
|
|
||||||
public void setDisconnectTimeout(long disconnectTimeout) {
|
|
||||||
Assert.isTrue(disconnectTimeout > 0, "DisconnectTimeout must be a positive value");
|
|
||||||
this.disconnectTimeout = disconnectTimeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The main server thread used to transfer tunnel traffic.
|
|
||||||
*/
|
|
||||||
protected class ServerThread extends Thread {
|
|
||||||
|
|
||||||
private final ByteChannel targetServer;
|
|
||||||
|
|
||||||
private final Deque<HttpConnection> httpConnections;
|
|
||||||
|
|
||||||
private final HttpTunnelPayloadForwarder payloadForwarder;
|
|
||||||
|
|
||||||
private boolean closed;
|
|
||||||
|
|
||||||
private final AtomicLong responseSeq = new AtomicLong();
|
|
||||||
|
|
||||||
private long lastHttpRequestTime;
|
|
||||||
|
|
||||||
public ServerThread(ByteChannel targetServer) {
|
|
||||||
Assert.notNull(targetServer, "TargetServer must not be null");
|
|
||||||
this.targetServer = targetServer;
|
|
||||||
this.httpConnections = new ArrayDeque<>(2);
|
|
||||||
this.payloadForwarder = new HttpTunnelPayloadForwarder(targetServer);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
try {
|
|
||||||
readAndForwardTargetServerData();
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
logger.trace("Unexpected exception from tunnel server", ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
finally {
|
|
||||||
this.closed = true;
|
|
||||||
closeHttpConnections();
|
|
||||||
closeTargetServer();
|
|
||||||
HttpTunnelServer.this.clearServerThread();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void readAndForwardTargetServerData() throws IOException {
|
|
||||||
while (this.targetServer.isOpen()) {
|
|
||||||
closeStaleHttpConnections();
|
|
||||||
ByteBuffer data = HttpTunnelPayload.getPayloadData(this.targetServer);
|
|
||||||
synchronized (this.httpConnections) {
|
|
||||||
if (data != null) {
|
|
||||||
HttpTunnelPayload payload = new HttpTunnelPayload(this.responseSeq.incrementAndGet(), data);
|
|
||||||
payload.logIncoming();
|
|
||||||
HttpConnection connection = getOrWaitForHttpConnection();
|
|
||||||
connection.respond(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpConnection getOrWaitForHttpConnection() {
|
|
||||||
synchronized (this.httpConnections) {
|
|
||||||
HttpConnection httpConnection = this.httpConnections.pollFirst();
|
|
||||||
while (httpConnection == null) {
|
|
||||||
try {
|
|
||||||
this.httpConnections.wait(HttpTunnelServer.this.longPollTimeout);
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
closeHttpConnections();
|
|
||||||
}
|
|
||||||
httpConnection = this.httpConnections.pollFirst();
|
|
||||||
}
|
|
||||||
return httpConnection;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void closeStaleHttpConnections() throws IOException {
|
|
||||||
synchronized (this.httpConnections) {
|
|
||||||
checkNotDisconnected();
|
|
||||||
Iterator<HttpConnection> iterator = this.httpConnections.iterator();
|
|
||||||
while (iterator.hasNext()) {
|
|
||||||
HttpConnection httpConnection = iterator.next();
|
|
||||||
if (httpConnection.isOlderThan(HttpTunnelServer.this.longPollTimeout)) {
|
|
||||||
httpConnection.respond(HttpStatus.NO_CONTENT);
|
|
||||||
iterator.remove();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void checkNotDisconnected() {
|
|
||||||
if (this.lastHttpRequestTime > 0) {
|
|
||||||
long timeout = HttpTunnelServer.this.disconnectTimeout;
|
|
||||||
long duration = System.currentTimeMillis() - this.lastHttpRequestTime;
|
|
||||||
Assert.state(duration < timeout, () -> "Disconnect timeout: " + timeout + " " + duration);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void closeHttpConnections() {
|
|
||||||
synchronized (this.httpConnections) {
|
|
||||||
while (!this.httpConnections.isEmpty()) {
|
|
||||||
try {
|
|
||||||
this.httpConnections.removeFirst().respond(HttpStatus.GONE);
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
logger.trace("Unable to close remote HTTP connection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void closeTargetServer() {
|
|
||||||
try {
|
|
||||||
this.targetServer.close();
|
|
||||||
}
|
|
||||||
catch (IOException ex) {
|
|
||||||
logger.trace("Unable to target server connection");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Handle an incoming {@link HttpConnection}.
|
|
||||||
* @param httpConnection the connection to handle.
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void handleIncomingHttp(HttpConnection httpConnection) throws IOException {
|
|
||||||
if (this.closed) {
|
|
||||||
httpConnection.respond(HttpStatus.GONE);
|
|
||||||
}
|
|
||||||
synchronized (this.httpConnections) {
|
|
||||||
while (this.httpConnections.size() > 1) {
|
|
||||||
this.httpConnections.removeFirst().respond(HttpStatus.TOO_MANY_REQUESTS);
|
|
||||||
}
|
|
||||||
this.lastHttpRequestTime = System.currentTimeMillis();
|
|
||||||
this.httpConnections.addLast(httpConnection);
|
|
||||||
this.httpConnections.notify();
|
|
||||||
}
|
|
||||||
forwardToTargetServer(httpConnection);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void forwardToTargetServer(HttpConnection httpConnection) throws IOException {
|
|
||||||
if (httpConnection.isDisconnectRequest()) {
|
|
||||||
this.targetServer.close();
|
|
||||||
interrupt();
|
|
||||||
}
|
|
||||||
ServerHttpRequest request = httpConnection.getRequest();
|
|
||||||
HttpTunnelPayload payload = HttpTunnelPayload.get(request);
|
|
||||||
if (payload != null) {
|
|
||||||
this.payloadForwarder.forward(payload);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Encapsulates an HTTP request/response pair.
|
|
||||||
*/
|
|
||||||
protected static class HttpConnection {
|
|
||||||
|
|
||||||
private final long createTime;
|
|
||||||
|
|
||||||
private final ServerHttpRequest request;
|
|
||||||
|
|
||||||
private final ServerHttpResponse response;
|
|
||||||
|
|
||||||
private final ServerHttpAsyncRequestControl async;
|
|
||||||
|
|
||||||
private volatile boolean complete = false;
|
|
||||||
|
|
||||||
public HttpConnection(ServerHttpRequest request, ServerHttpResponse response) {
|
|
||||||
this.createTime = System.currentTimeMillis();
|
|
||||||
this.request = request;
|
|
||||||
this.response = response;
|
|
||||||
this.async = startAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Start asynchronous support or if unavailable return {@code null} to cause
|
|
||||||
* {@link #waitForResponse()} to block.
|
|
||||||
* @return the async request control
|
|
||||||
*/
|
|
||||||
protected ServerHttpAsyncRequestControl startAsync() {
|
|
||||||
try {
|
|
||||||
// Try to use async to save blocking
|
|
||||||
ServerHttpAsyncRequestControl async = this.request.getAsyncRequestControl(this.response);
|
|
||||||
async.start();
|
|
||||||
return async;
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the underlying request.
|
|
||||||
* @return the request
|
|
||||||
*/
|
|
||||||
public final ServerHttpRequest getRequest() {
|
|
||||||
return this.request;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the underlying response.
|
|
||||||
* @return the response
|
|
||||||
*/
|
|
||||||
protected final ServerHttpResponse getResponse() {
|
|
||||||
return this.response;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine if a connection is older than the specified time.
|
|
||||||
* @param time the time to check
|
|
||||||
* @return {@code true} if the request is older than the time
|
|
||||||
*/
|
|
||||||
public boolean isOlderThan(int time) {
|
|
||||||
long runningTime = System.currentTimeMillis() - this.createTime;
|
|
||||||
return (runningTime > time);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Cause the request to block or use asynchronous methods to wait until a response
|
|
||||||
* is available.
|
|
||||||
*/
|
|
||||||
public void waitForResponse() {
|
|
||||||
if (this.async == null) {
|
|
||||||
while (!this.complete) {
|
|
||||||
try {
|
|
||||||
synchronized (this) {
|
|
||||||
wait(1000);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
Thread.currentThread().interrupt();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Detect if the request is actually a signal to disconnect.
|
|
||||||
* @return if the request is a signal to disconnect
|
|
||||||
*/
|
|
||||||
public boolean isDisconnectRequest() {
|
|
||||||
return DISCONNECT_MEDIA_TYPE.equals(this.request.getHeaders().getContentType());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send an HTTP status response.
|
|
||||||
* @param status the status to send
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void respond(HttpStatus status) throws IOException {
|
|
||||||
Assert.notNull(status, "Status must not be null");
|
|
||||||
this.response.setStatusCode(status);
|
|
||||||
complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Send a payload response.
|
|
||||||
* @param payload the payload to send
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
public void respond(HttpTunnelPayload payload) throws IOException {
|
|
||||||
Assert.notNull(payload, "Payload must not be null");
|
|
||||||
this.response.setStatusCode(HttpStatus.OK);
|
|
||||||
payload.assignTo(this.response);
|
|
||||||
complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Called when a request is complete.
|
|
||||||
*/
|
|
||||||
protected void complete() {
|
|
||||||
if (this.async != null) {
|
|
||||||
this.async.complete();
|
|
||||||
}
|
|
||||||
else {
|
|
||||||
synchronized (this) {
|
|
||||||
this.complete = true;
|
|
||||||
notifyAll();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,50 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2022 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
|
|
||||||
import org.springframework.boot.devtools.remote.server.Handler;
|
|
||||||
import org.springframework.http.server.ServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServerHttpResponse;
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Adapts a {@link HttpTunnelServer} to a {@link Handler}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public class HttpTunnelServerHandler implements Handler {
|
|
||||||
|
|
||||||
private final HttpTunnelServer server;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new {@link HttpTunnelServerHandler} instance.
|
|
||||||
* @param server the server to adapt
|
|
||||||
*/
|
|
||||||
public HttpTunnelServerHandler(HttpTunnelServer server) {
|
|
||||||
Assert.notNull(server, "Server must not be null");
|
|
||||||
this.server = server;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void handle(ServerHttpRequest request, ServerHttpResponse response) throws IOException {
|
|
||||||
this.server.handle(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,35 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Strategy interface to provide access to a port (which may change if an existing
|
|
||||||
* connection is closed).
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface PortProvider {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Return the port number.
|
|
||||||
* @return the port number
|
|
||||||
*/
|
|
||||||
int getPort();
|
|
||||||
|
|
||||||
}
|
|
@ -1,101 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.SocketAddress;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.ByteChannel;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.ReadableByteChannel;
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
|
|
||||||
import org.apache.commons.logging.Log;
|
|
||||||
import org.apache.commons.logging.LogFactory;
|
|
||||||
|
|
||||||
import org.springframework.core.log.LogMessage;
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Socket based {@link TargetServerConnection}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public class SocketTargetServerConnection implements TargetServerConnection {
|
|
||||||
|
|
||||||
private static final Log logger = LogFactory.getLog(SocketTargetServerConnection.class);
|
|
||||||
|
|
||||||
private final PortProvider portProvider;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Create a new {@link SocketTargetServerConnection}.
|
|
||||||
* @param portProvider the port provider
|
|
||||||
*/
|
|
||||||
public SocketTargetServerConnection(PortProvider portProvider) {
|
|
||||||
Assert.notNull(portProvider, "PortProvider must not be null");
|
|
||||||
this.portProvider = portProvider;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public ByteChannel open(int socketTimeout) throws IOException {
|
|
||||||
SocketAddress address = new InetSocketAddress(this.portProvider.getPort());
|
|
||||||
logger.trace(LogMessage.format("Opening tunnel connection to target server on %s", address));
|
|
||||||
SocketChannel channel = SocketChannel.open(address);
|
|
||||||
channel.socket().setSoTimeout(socketTimeout);
|
|
||||||
return new TimeoutAwareChannel(channel);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper to expose the {@link SocketChannel} in such a way that
|
|
||||||
* {@code SocketTimeoutExceptions} are still thrown from read methods.
|
|
||||||
*/
|
|
||||||
private static class TimeoutAwareChannel implements ByteChannel {
|
|
||||||
|
|
||||||
private final SocketChannel socketChannel;
|
|
||||||
|
|
||||||
private final ReadableByteChannel readChannel;
|
|
||||||
|
|
||||||
TimeoutAwareChannel(SocketChannel socketChannel) throws IOException {
|
|
||||||
this.socketChannel = socketChannel;
|
|
||||||
this.readChannel = Channels.newChannel(socketChannel.socket().getInputStream());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int read(ByteBuffer dst) throws IOException {
|
|
||||||
return this.readChannel.read(dst);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int write(ByteBuffer src) throws IOException {
|
|
||||||
return this.socketChannel.write(src);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOpen() {
|
|
||||||
return this.socketChannel.isOpen();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws IOException {
|
|
||||||
this.socketChannel.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,41 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import org.springframework.util.Assert;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* {@link PortProvider} for a static port that won't change.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
public class StaticPortProvider implements PortProvider {
|
|
||||||
|
|
||||||
private final int port;
|
|
||||||
|
|
||||||
public StaticPortProvider(int port) {
|
|
||||||
Assert.isTrue(port > 0, "Port must be positive");
|
|
||||||
this.port = port;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int getPort() {
|
|
||||||
return this.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,39 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.nio.channels.ByteChannel;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the connection to the ultimate tunnel target server.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @since 1.3.0
|
|
||||||
*/
|
|
||||||
@FunctionalInterface
|
|
||||||
public interface TargetServerConnection {
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Open a connection to the target server with the specified timeout.
|
|
||||||
* @param timeout the read timeout
|
|
||||||
* @return a {@link ByteChannel} providing read/write access to the server
|
|
||||||
* @throws IOException in case of I/O errors
|
|
||||||
*/
|
|
||||||
ByteChannel open(int timeout) throws IOException;
|
|
||||||
|
|
||||||
}
|
|
@ -1,20 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Server side TCP tunnel support.
|
|
||||||
*/
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
@ -1,163 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2019 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.integrationtest;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.util.Collection;
|
|
||||||
import java.util.Collections;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import org.springframework.beans.factory.annotation.Value;
|
|
||||||
import org.springframework.boot.devtools.integrationtest.HttpTunnelIntegrationTests.TunnelConfiguration.TestTunnelClient;
|
|
||||||
import org.springframework.boot.devtools.remote.server.AccessManager;
|
|
||||||
import org.springframework.boot.devtools.remote.server.Dispatcher;
|
|
||||||
import org.springframework.boot.devtools.remote.server.DispatcherFilter;
|
|
||||||
import org.springframework.boot.devtools.remote.server.HandlerMapper;
|
|
||||||
import org.springframework.boot.devtools.remote.server.UrlHandlerMapper;
|
|
||||||
import org.springframework.boot.devtools.tunnel.client.HttpTunnelConnection;
|
|
||||||
import org.springframework.boot.devtools.tunnel.client.TunnelClient;
|
|
||||||
import org.springframework.boot.devtools.tunnel.client.TunnelConnection;
|
|
||||||
import org.springframework.boot.devtools.tunnel.server.HttpTunnelServer;
|
|
||||||
import org.springframework.boot.devtools.tunnel.server.HttpTunnelServerHandler;
|
|
||||||
import org.springframework.boot.devtools.tunnel.server.SocketTargetServerConnection;
|
|
||||||
import org.springframework.boot.devtools.tunnel.server.TargetServerConnection;
|
|
||||||
import org.springframework.boot.test.util.TestPropertyValues;
|
|
||||||
import org.springframework.boot.test.web.client.TestRestTemplate;
|
|
||||||
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory;
|
|
||||||
import org.springframework.boot.web.servlet.context.AnnotationConfigServletWebServerApplicationContext;
|
|
||||||
import org.springframework.boot.web.servlet.server.ServletWebServerFactory;
|
|
||||||
import org.springframework.context.annotation.AnnotationConfigApplicationContext;
|
|
||||||
import org.springframework.context.annotation.Bean;
|
|
||||||
import org.springframework.context.annotation.Configuration;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.ResponseEntity;
|
|
||||||
import org.springframework.http.client.SimpleClientHttpRequestFactory;
|
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
|
||||||
import org.springframework.web.servlet.DispatcherServlet;
|
|
||||||
import org.springframework.web.servlet.config.annotation.EnableWebMvc;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Simple integration tests for HTTP tunneling.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class HttpTunnelIntegrationTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpServerDirect() {
|
|
||||||
AnnotationConfigServletWebServerApplicationContext context = new AnnotationConfigServletWebServerApplicationContext();
|
|
||||||
context.register(ServerConfiguration.class);
|
|
||||||
context.refresh();
|
|
||||||
String url = "http://localhost:" + context.getWebServer().getPort() + "/hello";
|
|
||||||
ResponseEntity<String> entity = new TestRestTemplate().getForEntity(url, String.class);
|
|
||||||
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
assertThat(entity.getBody()).isEqualTo("Hello World");
|
|
||||||
context.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void viaTunnel() {
|
|
||||||
AnnotationConfigServletWebServerApplicationContext serverContext = new AnnotationConfigServletWebServerApplicationContext();
|
|
||||||
serverContext.register(ServerConfiguration.class);
|
|
||||||
serverContext.refresh();
|
|
||||||
AnnotationConfigApplicationContext tunnelContext = new AnnotationConfigApplicationContext();
|
|
||||||
TestPropertyValues.of("server.port:" + serverContext.getWebServer().getPort()).applyTo(tunnelContext);
|
|
||||||
tunnelContext.register(TunnelConfiguration.class);
|
|
||||||
tunnelContext.refresh();
|
|
||||||
String url = "http://localhost:" + tunnelContext.getBean(TestTunnelClient.class).port + "/hello";
|
|
||||||
ResponseEntity<String> entity = new TestRestTemplate().getForEntity(url, String.class);
|
|
||||||
assertThat(entity.getStatusCode()).isEqualTo(HttpStatus.OK);
|
|
||||||
assertThat(entity.getBody()).isEqualTo("Hello World");
|
|
||||||
serverContext.close();
|
|
||||||
tunnelContext.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Configuration(proxyBeanMethods = false)
|
|
||||||
@EnableWebMvc
|
|
||||||
static class ServerConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
ServletWebServerFactory container() {
|
|
||||||
return new TomcatServletWebServerFactory(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
DispatcherServlet dispatcherServlet() {
|
|
||||||
return new DispatcherServlet();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
MyController myController() {
|
|
||||||
return new MyController();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
DispatcherFilter filter(AnnotationConfigServletWebServerApplicationContext context) {
|
|
||||||
TargetServerConnection connection = new SocketTargetServerConnection(
|
|
||||||
() -> context.getWebServer().getPort());
|
|
||||||
HttpTunnelServer server = new HttpTunnelServer(connection);
|
|
||||||
HandlerMapper mapper = new UrlHandlerMapper("/httptunnel", new HttpTunnelServerHandler(server));
|
|
||||||
Collection<HandlerMapper> mappers = Collections.singleton(mapper);
|
|
||||||
Dispatcher dispatcher = new Dispatcher(AccessManager.PERMIT_ALL, mappers);
|
|
||||||
return new DispatcherFilter(dispatcher);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@org.springframework.context.annotation.Configuration(proxyBeanMethods = false)
|
|
||||||
static class TunnelConfiguration {
|
|
||||||
|
|
||||||
@Bean
|
|
||||||
TunnelClient tunnelClient(@Value("${server.port}") int serverPort) {
|
|
||||||
String url = "http://localhost:" + serverPort + "/httptunnel";
|
|
||||||
TunnelConnection connection = new HttpTunnelConnection(url, new SimpleClientHttpRequestFactory());
|
|
||||||
return new TestTunnelClient(0, connection);
|
|
||||||
}
|
|
||||||
|
|
||||||
static class TestTunnelClient extends TunnelClient {
|
|
||||||
|
|
||||||
private int port;
|
|
||||||
|
|
||||||
TestTunnelClient(int listenPort, TunnelConnection tunnelConnection) {
|
|
||||||
super(listenPort, tunnelConnection);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int start() throws IOException {
|
|
||||||
this.port = super.start();
|
|
||||||
return this.port;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
@RestController
|
|
||||||
static class MyController {
|
|
||||||
|
|
||||||
@RequestMapping("/hello")
|
|
||||||
String hello() {
|
|
||||||
return "Hello World";
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,167 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.Closeable;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.ConnectException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
import java.util.concurrent.Executor;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import org.springframework.boot.devtools.test.MockClientHttpRequestFactory;
|
|
||||||
import org.springframework.boot.devtools.tunnel.client.HttpTunnelConnection.TunnelChannel;
|
|
||||||
import org.springframework.boot.test.system.CapturedOutput;
|
|
||||||
import org.springframework.boot.test.system.OutputCaptureExtension;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
import static org.mockito.BDDMockito.then;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link HttpTunnelConnection}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
* @author Rob Winch
|
|
||||||
* @author Andy Wilkinson
|
|
||||||
*/
|
|
||||||
@ExtendWith({ OutputCaptureExtension.class, MockitoExtension.class })
|
|
||||||
class HttpTunnelConnectionTests {
|
|
||||||
|
|
||||||
private String url;
|
|
||||||
|
|
||||||
private ByteArrayOutputStream incomingData;
|
|
||||||
|
|
||||||
private WritableByteChannel incomingChannel;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private Closeable closeable;
|
|
||||||
|
|
||||||
private final MockClientHttpRequestFactory requestFactory = new MockClientHttpRequestFactory();
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() {
|
|
||||||
this.url = "http://localhost:12345";
|
|
||||||
this.incomingData = new ByteArrayOutputStream();
|
|
||||||
this.incomingChannel = Channels.newChannel(this.incomingData);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void urlMustNotBeNull() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelConnection(null, this.requestFactory))
|
|
||||||
.withMessageContaining("URL must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void urlMustNotBeEmpty() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelConnection("", this.requestFactory))
|
|
||||||
.withMessageContaining("URL must not be empty");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void urlMustNotBeMalformed() {
|
|
||||||
assertThatIllegalArgumentException()
|
|
||||||
.isThrownBy(() -> new HttpTunnelConnection("htttttp:///ttest", this.requestFactory))
|
|
||||||
.withMessageContaining("Malformed URL 'htttttp:///ttest'");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void requestFactoryMustNotBeNull() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelConnection(this.url, null))
|
|
||||||
.withMessageContaining("RequestFactory must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void closeTunnelChangesIsOpen() throws Exception {
|
|
||||||
this.requestFactory.willRespondAfterDelay(1000, HttpStatus.GONE);
|
|
||||||
WritableByteChannel channel = openTunnel(false);
|
|
||||||
assertThat(channel.isOpen()).isTrue();
|
|
||||||
channel.close();
|
|
||||||
assertThat(channel.isOpen()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void closeTunnelCallsCloseableOnce() throws Exception {
|
|
||||||
this.requestFactory.willRespondAfterDelay(1000, HttpStatus.GONE);
|
|
||||||
WritableByteChannel channel = openTunnel(false);
|
|
||||||
then(this.closeable).should(never()).close();
|
|
||||||
channel.close();
|
|
||||||
channel.close();
|
|
||||||
then(this.closeable).should().close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void typicalTraffic() throws Exception {
|
|
||||||
this.requestFactory.willRespond("hi", "=2", "=3");
|
|
||||||
TunnelChannel channel = openTunnel(true);
|
|
||||||
write(channel, "hello");
|
|
||||||
write(channel, "1+1");
|
|
||||||
write(channel, "1+2");
|
|
||||||
assertThat(this.incomingData).hasToString("hi=2=3");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void trafficWithLongPollTimeouts() throws Exception {
|
|
||||||
for (int i = 0; i < 10; i++) {
|
|
||||||
this.requestFactory.willRespond(HttpStatus.NO_CONTENT);
|
|
||||||
}
|
|
||||||
this.requestFactory.willRespond("hi");
|
|
||||||
TunnelChannel channel = openTunnel(true);
|
|
||||||
write(channel, "hello");
|
|
||||||
assertThat(this.incomingData).hasToString("hi");
|
|
||||||
assertThat(this.requestFactory.getExecutedRequests()).hasSizeGreaterThan(10);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void connectFailureLogsWarning(CapturedOutput output) throws Exception {
|
|
||||||
this.requestFactory.willRespond(new ConnectException());
|
|
||||||
try (TunnelChannel tunnel = openTunnel(true)) {
|
|
||||||
assertThat(tunnel.isOpen()).isFalse();
|
|
||||||
assertThat(output).contains("Failed to connect to remote application at http://localhost:12345");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private void write(TunnelChannel channel, String string) throws IOException {
|
|
||||||
channel.write(ByteBuffer.wrap(string.getBytes()));
|
|
||||||
}
|
|
||||||
|
|
||||||
private TunnelChannel openTunnel(boolean singleThreaded) throws Exception {
|
|
||||||
HttpTunnelConnection connection = new HttpTunnelConnection(this.url, this.requestFactory,
|
|
||||||
singleThreaded ? new CurrentThreadExecutor() : null);
|
|
||||||
return connection.open(this.incomingChannel, this.closeable);
|
|
||||||
}
|
|
||||||
|
|
||||||
static class CurrentThreadExecutor implements Executor {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void execute(Runnable command) {
|
|
||||||
command.run();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,220 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.client;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.Closeable;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.SocketException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
|
||||||
|
|
||||||
import org.awaitility.Awaitility;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link TunnelClient}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class TunnelClientTests {
|
|
||||||
|
|
||||||
private final MockTunnelConnection tunnelConnection = new MockTunnelConnection();
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void listenPortMustNotBeNegative() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new TunnelClient(-5, this.tunnelConnection))
|
|
||||||
.withMessageContaining("ListenPort must be greater than or equal to 0");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void tunnelConnectionMustNotBeNull() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new TunnelClient(1, null))
|
|
||||||
.withMessageContaining("TunnelConnection must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void typicalTraffic() throws Exception {
|
|
||||||
TunnelClient client = new TunnelClient(0, this.tunnelConnection);
|
|
||||||
int port = client.start();
|
|
||||||
SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
|
|
||||||
channel.write(ByteBuffer.wrap("hello".getBytes()));
|
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(5);
|
|
||||||
channel.read(buffer);
|
|
||||||
channel.close();
|
|
||||||
this.tunnelConnection.verifyWritten("hello");
|
|
||||||
assertThat(new String(buffer.array())).isEqualTo("olleh");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void socketChannelClosedTriggersTunnelClose() throws Exception {
|
|
||||||
TunnelClient client = new TunnelClient(0, this.tunnelConnection);
|
|
||||||
int port = client.start();
|
|
||||||
SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
|
|
||||||
Awaitility.await()
|
|
||||||
.atMost(Duration.ofSeconds(30))
|
|
||||||
.until(this.tunnelConnection::getOpenedTimes, (open) -> open == 1);
|
|
||||||
channel.close();
|
|
||||||
client.getServerThread().stopAcceptingConnections();
|
|
||||||
client.getServerThread().join(2000);
|
|
||||||
assertThat(this.tunnelConnection.getOpenedTimes()).isOne();
|
|
||||||
assertThat(this.tunnelConnection.isOpen()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void stopTriggersTunnelClose() throws Exception {
|
|
||||||
TunnelClient client = new TunnelClient(0, this.tunnelConnection);
|
|
||||||
int port = client.start();
|
|
||||||
SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
|
|
||||||
Awaitility.await()
|
|
||||||
.atMost(Duration.ofSeconds(30))
|
|
||||||
.until(this.tunnelConnection::getOpenedTimes, (times) -> times == 1);
|
|
||||||
assertThat(this.tunnelConnection.isOpen()).isTrue();
|
|
||||||
client.stop();
|
|
||||||
assertThat(this.tunnelConnection.isOpen()).isFalse();
|
|
||||||
assertThat(readWithPossibleFailure(channel)).satisfiesAnyOf((result) -> assertThat(result).isEqualTo(-1),
|
|
||||||
(result) -> assertThat(result).isInstanceOf(SocketException.class));
|
|
||||||
}
|
|
||||||
|
|
||||||
private Object readWithPossibleFailure(SocketChannel channel) {
|
|
||||||
try {
|
|
||||||
return channel.read(ByteBuffer.allocate(1));
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
return ex;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void addListener() throws Exception {
|
|
||||||
TunnelClient client = new TunnelClient(0, this.tunnelConnection);
|
|
||||||
MockTunnelClientListener listener = new MockTunnelClientListener();
|
|
||||||
client.addListener(listener);
|
|
||||||
int port = client.start();
|
|
||||||
SocketChannel channel = SocketChannel.open(new InetSocketAddress(port));
|
|
||||||
Awaitility.await().atMost(Duration.ofSeconds(30)).until(listener.onOpen::get, (open) -> open == 1);
|
|
||||||
assertThat(listener.onClose).hasValue(0);
|
|
||||||
client.getServerThread().stopAcceptingConnections();
|
|
||||||
channel.close();
|
|
||||||
Awaitility.await().atMost(Duration.ofSeconds(30)).until(listener.onClose::get, (close) -> close == 1);
|
|
||||||
client.getServerThread().join(2000);
|
|
||||||
}
|
|
||||||
|
|
||||||
static class MockTunnelClientListener implements TunnelClientListener {
|
|
||||||
|
|
||||||
private final AtomicInteger onOpen = new AtomicInteger();
|
|
||||||
|
|
||||||
private final AtomicInteger onClose = new AtomicInteger();
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onOpen(SocketChannel socket) {
|
|
||||||
this.onOpen.incrementAndGet();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void onClose(SocketChannel socket) {
|
|
||||||
this.onClose.incrementAndGet();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
static class MockTunnelConnection implements TunnelConnection {
|
|
||||||
|
|
||||||
private final ByteArrayOutputStream written = new ByteArrayOutputStream();
|
|
||||||
|
|
||||||
private boolean open;
|
|
||||||
|
|
||||||
private int openedTimes;
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public WritableByteChannel open(WritableByteChannel incomingChannel, Closeable closeable) {
|
|
||||||
this.openedTimes++;
|
|
||||||
this.open = true;
|
|
||||||
return new TunnelChannel(incomingChannel, closeable);
|
|
||||||
}
|
|
||||||
|
|
||||||
void verifyWritten(String expected) {
|
|
||||||
verifyWritten(expected.getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
void verifyWritten(byte[] expected) {
|
|
||||||
synchronized (this.written) {
|
|
||||||
assertThat(this.written.toByteArray()).isEqualTo(expected);
|
|
||||||
this.written.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
boolean isOpen() {
|
|
||||||
return this.open;
|
|
||||||
}
|
|
||||||
|
|
||||||
int getOpenedTimes() {
|
|
||||||
return this.openedTimes;
|
|
||||||
}
|
|
||||||
|
|
||||||
private class TunnelChannel implements WritableByteChannel {
|
|
||||||
|
|
||||||
private final WritableByteChannel incomingChannel;
|
|
||||||
|
|
||||||
private final Closeable closeable;
|
|
||||||
|
|
||||||
TunnelChannel(WritableByteChannel incomingChannel, Closeable closeable) {
|
|
||||||
this.incomingChannel = incomingChannel;
|
|
||||||
this.closeable = closeable;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOpen() {
|
|
||||||
return MockTunnelConnection.this.open;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() throws IOException {
|
|
||||||
MockTunnelConnection.this.open = false;
|
|
||||||
this.closeable.close();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int write(ByteBuffer src) throws IOException {
|
|
||||||
int remaining = src.remaining();
|
|
||||||
ByteArrayOutputStream stream = new ByteArrayOutputStream();
|
|
||||||
Channels.newChannel(stream).write(src);
|
|
||||||
byte[] bytes = stream.toByteArray();
|
|
||||||
synchronized (MockTunnelConnection.this.written) {
|
|
||||||
MockTunnelConnection.this.written.write(bytes);
|
|
||||||
}
|
|
||||||
byte[] reversed = new byte[bytes.length];
|
|
||||||
for (int i = 0; i < reversed.length; i++) {
|
|
||||||
reversed[i] = bytes[bytes.length - 1 - i];
|
|
||||||
}
|
|
||||||
this.incomingChannel.write(ByteBuffer.wrap(reversed));
|
|
||||||
return remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,80 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.payload;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link HttpTunnelPayloadForwarder}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class HttpTunnelPayloadForwarderTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void targetChannelMustNotBeNull() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelPayloadForwarder(null))
|
|
||||||
.withMessageContaining("TargetChannel must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void forwardInSequence() throws Exception {
|
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
||||||
WritableByteChannel channel = Channels.newChannel(out);
|
|
||||||
HttpTunnelPayloadForwarder forwarder = new HttpTunnelPayloadForwarder(channel);
|
|
||||||
forwarder.forward(payload(1, "he"));
|
|
||||||
forwarder.forward(payload(2, "ll"));
|
|
||||||
forwarder.forward(payload(3, "o"));
|
|
||||||
assertThat(out.toByteArray()).isEqualTo("hello".getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void forwardOutOfSequence() throws Exception {
|
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
||||||
WritableByteChannel channel = Channels.newChannel(out);
|
|
||||||
HttpTunnelPayloadForwarder forwarder = new HttpTunnelPayloadForwarder(channel);
|
|
||||||
forwarder.forward(payload(3, "o"));
|
|
||||||
forwarder.forward(payload(2, "ll"));
|
|
||||||
forwarder.forward(payload(1, "he"));
|
|
||||||
assertThat(out.toByteArray()).isEqualTo("hello".getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void overflow() {
|
|
||||||
WritableByteChannel channel = Channels.newChannel(new ByteArrayOutputStream());
|
|
||||||
HttpTunnelPayloadForwarder forwarder = new HttpTunnelPayloadForwarder(channel);
|
|
||||||
assertThatIllegalStateException().isThrownBy(() -> {
|
|
||||||
for (int i = 2; i < 130; i++) {
|
|
||||||
forwarder.forward(payload(i, "data" + i));
|
|
||||||
}
|
|
||||||
}).withMessageContaining("Too many messages queued");
|
|
||||||
}
|
|
||||||
|
|
||||||
private HttpTunnelPayload payload(long sequence, String data) {
|
|
||||||
return new HttpTunnelPayload(sequence, ByteBuffer.wrap(data.getBytes()));
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,142 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.payload;
|
|
||||||
|
|
||||||
import java.io.ByteArrayInputStream;
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.nio.channels.ReadableByteChannel;
|
|
||||||
import java.nio.channels.WritableByteChannel;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import org.springframework.http.HttpInputMessage;
|
|
||||||
import org.springframework.http.HttpOutputMessage;
|
|
||||||
import org.springframework.http.server.ServletServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServletServerHttpResponse;
|
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalStateException;
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
|
||||||
import static org.mockito.BDDMockito.given;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link HttpTunnelPayload}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class HttpTunnelPayloadTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void sequenceMustBePositive() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelPayload(0, ByteBuffer.allocate(1)))
|
|
||||||
.withMessageContaining("Sequence must be positive");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void dataMustNotBeNull() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelPayload(1, null))
|
|
||||||
.withMessageContaining("Data must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getSequence() {
|
|
||||||
HttpTunnelPayload payload = new HttpTunnelPayload(1, ByteBuffer.allocate(1));
|
|
||||||
assertThat(payload.getSequence()).isOne();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getData() throws Exception {
|
|
||||||
ByteBuffer data = ByteBuffer.wrap("hello".getBytes());
|
|
||||||
HttpTunnelPayload payload = new HttpTunnelPayload(1, data);
|
|
||||||
assertThat(getData(payload)).isEqualTo(data.array());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void assignTo() throws Exception {
|
|
||||||
ByteBuffer data = ByteBuffer.wrap("hello".getBytes());
|
|
||||||
HttpTunnelPayload payload = new HttpTunnelPayload(2, data);
|
|
||||||
MockHttpServletResponse servletResponse = new MockHttpServletResponse();
|
|
||||||
HttpOutputMessage response = new ServletServerHttpResponse(servletResponse);
|
|
||||||
payload.assignTo(response);
|
|
||||||
assertThat(servletResponse.getHeader("x-seq")).isEqualTo("2");
|
|
||||||
assertThat(servletResponse.getContentAsString()).isEqualTo("hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getNoData() throws Exception {
|
|
||||||
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
|
|
||||||
HttpInputMessage request = new ServletServerHttpRequest(servletRequest);
|
|
||||||
HttpTunnelPayload payload = HttpTunnelPayload.get(request);
|
|
||||||
assertThat(payload).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getWithMissingHeader() {
|
|
||||||
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
|
|
||||||
servletRequest.setContent("hello".getBytes());
|
|
||||||
HttpInputMessage request = new ServletServerHttpRequest(servletRequest);
|
|
||||||
assertThatIllegalStateException().isThrownBy(() -> HttpTunnelPayload.get(request))
|
|
||||||
.withMessageContaining("Missing sequence header");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getWithData() throws Exception {
|
|
||||||
MockHttpServletRequest servletRequest = new MockHttpServletRequest();
|
|
||||||
servletRequest.setContent("hello".getBytes());
|
|
||||||
servletRequest.addHeader("x-seq", 123);
|
|
||||||
HttpInputMessage request = new ServletServerHttpRequest(servletRequest);
|
|
||||||
HttpTunnelPayload payload = HttpTunnelPayload.get(request);
|
|
||||||
assertThat(payload.getSequence()).isEqualTo(123L);
|
|
||||||
assertThat(getData(payload)).isEqualTo("hello".getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getPayloadData() throws Exception {
|
|
||||||
ReadableByteChannel channel = Channels.newChannel(new ByteArrayInputStream("hello".getBytes()));
|
|
||||||
ByteBuffer payloadData = HttpTunnelPayload.getPayloadData(channel);
|
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
||||||
WritableByteChannel writeChannel = Channels.newChannel(out);
|
|
||||||
while (payloadData.hasRemaining()) {
|
|
||||||
writeChannel.write(payloadData);
|
|
||||||
}
|
|
||||||
assertThat(out.toByteArray()).isEqualTo("hello".getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getPayloadDataWithTimeout() throws Exception {
|
|
||||||
ReadableByteChannel channel = mock(ReadableByteChannel.class);
|
|
||||||
given(channel.read(any(ByteBuffer.class))).willThrow(new SocketTimeoutException());
|
|
||||||
ByteBuffer payload = HttpTunnelPayload.getPayloadData(channel);
|
|
||||||
assertThat(payload).isNull();
|
|
||||||
}
|
|
||||||
|
|
||||||
private byte[] getData(HttpTunnelPayload payload) throws IOException {
|
|
||||||
ByteArrayOutputStream out = new ByteArrayOutputStream();
|
|
||||||
WritableByteChannel channel = Channels.newChannel(out);
|
|
||||||
payload.writeTo(channel);
|
|
||||||
return out.toByteArray();
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,51 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import org.springframework.http.server.ServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServerHttpResponse;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
import static org.mockito.BDDMockito.then;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link HttpTunnelServerHandler}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class HttpTunnelServerHandlerTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void serverMustNotBeNull() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelServerHandler(null))
|
|
||||||
.withMessageContaining("Server must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void handleDelegatesToServer() throws Exception {
|
|
||||||
HttpTunnelServer server = mock(HttpTunnelServer.class);
|
|
||||||
HttpTunnelServerHandler handler = new HttpTunnelServerHandler(server);
|
|
||||||
ServerHttpRequest request = mock(ServerHttpRequest.class);
|
|
||||||
ServerHttpResponse response = mock(ServerHttpResponse.class);
|
|
||||||
handler.handle(request, response);
|
|
||||||
then(server).should().handle(request, response);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,479 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import java.io.ByteArrayOutputStream;
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.ByteChannel;
|
|
||||||
import java.nio.channels.Channels;
|
|
||||||
import java.time.Duration;
|
|
||||||
import java.util.concurrent.BlockingDeque;
|
|
||||||
import java.util.concurrent.LinkedBlockingDeque;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
|
||||||
import java.util.concurrent.atomic.AtomicBoolean;
|
|
||||||
|
|
||||||
import org.awaitility.Awaitility;
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
|
||||||
import org.mockito.Mock;
|
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
|
||||||
|
|
||||||
import org.springframework.boot.devtools.tunnel.payload.HttpTunnelPayload;
|
|
||||||
import org.springframework.boot.devtools.tunnel.server.HttpTunnelServer.HttpConnection;
|
|
||||||
import org.springframework.http.HttpStatus;
|
|
||||||
import org.springframework.http.server.ServerHttpAsyncRequestControl;
|
|
||||||
import org.springframework.http.server.ServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServerHttpResponse;
|
|
||||||
import org.springframework.http.server.ServletServerHttpRequest;
|
|
||||||
import org.springframework.http.server.ServletServerHttpResponse;
|
|
||||||
import org.springframework.mock.web.MockHttpServletRequest;
|
|
||||||
import org.springframework.mock.web.MockHttpServletResponse;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
import static org.mockito.ArgumentMatchers.anyInt;
|
|
||||||
import static org.mockito.BDDMockito.given;
|
|
||||||
import static org.mockito.BDDMockito.then;
|
|
||||||
import static org.mockito.Mockito.mock;
|
|
||||||
import static org.mockito.Mockito.never;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link HttpTunnelServer}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
@ExtendWith(MockitoExtension.class)
|
|
||||||
class HttpTunnelServerTests {
|
|
||||||
|
|
||||||
private static final int DEFAULT_LONG_POLL_TIMEOUT = 10000;
|
|
||||||
|
|
||||||
private static final int JOIN_TIMEOUT = 5000;
|
|
||||||
|
|
||||||
private static final byte[] NO_DATA = {};
|
|
||||||
|
|
||||||
private static final String SEQ_HEADER = "x-seq";
|
|
||||||
|
|
||||||
private HttpTunnelServer server;
|
|
||||||
|
|
||||||
@Mock
|
|
||||||
private TargetServerConnection serverConnection;
|
|
||||||
|
|
||||||
private MockHttpServletRequest servletRequest;
|
|
||||||
|
|
||||||
private MockHttpServletResponse servletResponse;
|
|
||||||
|
|
||||||
private ServerHttpRequest request;
|
|
||||||
|
|
||||||
private ServerHttpResponse response;
|
|
||||||
|
|
||||||
private MockServerChannel serverChannel;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() {
|
|
||||||
this.server = new HttpTunnelServer(this.serverConnection);
|
|
||||||
this.servletRequest = new MockHttpServletRequest();
|
|
||||||
this.servletRequest.setAsyncSupported(true);
|
|
||||||
this.servletResponse = new MockHttpServletResponse();
|
|
||||||
this.request = new ServletServerHttpRequest(this.servletRequest);
|
|
||||||
this.response = new ServletServerHttpResponse(this.servletResponse);
|
|
||||||
this.serverChannel = new MockServerChannel();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void serverConnectionIsRequired() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new HttpTunnelServer(null))
|
|
||||||
.withMessageContaining("ServerConnection must not be null");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void serverConnectedOnFirstRequest() throws Exception {
|
|
||||||
then(this.serverConnection).should(never()).open(anyInt());
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.server.handle(this.request, this.response);
|
|
||||||
then(this.serverConnection).should().open(DEFAULT_LONG_POLL_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void longPollTimeout() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.server.setLongPollTimeout(800);
|
|
||||||
this.server.handle(this.request, this.response);
|
|
||||||
then(this.serverConnection).should().open(800);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void longPollTimeoutMustBePositiveValue() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> this.server.setLongPollTimeout(0))
|
|
||||||
.withMessageContaining("LongPollTimeout must be a positive value");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initialRequestIsSentToServer() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.servletRequest.addHeader(SEQ_HEADER, "1");
|
|
||||||
this.servletRequest.setContent("hello".getBytes());
|
|
||||||
this.server.handle(this.request, this.response);
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
this.serverChannel.verifyReceived("hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initialRequestIsUsedForFirstServerResponse() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.servletRequest.addHeader(SEQ_HEADER, "1");
|
|
||||||
this.servletRequest.setContent("hello".getBytes());
|
|
||||||
this.server.handle(this.request, this.response);
|
|
||||||
System.out.println("sending");
|
|
||||||
this.serverChannel.send("hello");
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
assertThat(this.servletResponse.getContentAsString()).isEqualTo("hello");
|
|
||||||
this.serverChannel.verifyReceived("hello");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void initialRequestHasNoPayload() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.server.handle(this.request, this.response);
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
this.serverChannel.verifyReceived(NO_DATA);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void typicalRequestResponseTraffic() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection();
|
|
||||||
this.server.handle(h1);
|
|
||||||
MockHttpConnection h2 = new MockHttpConnection("hello server", 1);
|
|
||||||
this.server.handle(h2);
|
|
||||||
this.serverChannel.verifyReceived("hello server");
|
|
||||||
this.serverChannel.send("hello client");
|
|
||||||
h1.verifyReceived("hello client", 1);
|
|
||||||
MockHttpConnection h3 = new MockHttpConnection("1+1", 2);
|
|
||||||
this.server.handle(h3);
|
|
||||||
this.serverChannel.send("=2");
|
|
||||||
h2.verifyReceived("=2", 2);
|
|
||||||
MockHttpConnection h4 = new MockHttpConnection("1+2", 3);
|
|
||||||
this.server.handle(h4);
|
|
||||||
this.serverChannel.send("=3");
|
|
||||||
h3.verifyReceived("=3", 3);
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void clientIsAwareOfServerClose() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection("1", 1);
|
|
||||||
this.server.handle(h1);
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
assertThat(h1.getServletResponse().getStatus()).isEqualTo(410);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void clientCanCloseServer() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection();
|
|
||||||
this.server.handle(h1);
|
|
||||||
MockHttpConnection h2 = new MockHttpConnection("DISCONNECT", 1);
|
|
||||||
h2.getServletRequest().addHeader("Content-Type", "application/x-disconnect");
|
|
||||||
this.server.handle(h2);
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
assertThat(h1.getServletResponse().getStatus()).isEqualTo(410);
|
|
||||||
assertThat(this.serverChannel.isOpen()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void neverMoreThanTwoHttpConnections() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection();
|
|
||||||
this.server.handle(h1);
|
|
||||||
MockHttpConnection h2 = new MockHttpConnection("1", 2);
|
|
||||||
this.server.handle(h2);
|
|
||||||
MockHttpConnection h3 = new MockHttpConnection("2", 3);
|
|
||||||
this.server.handle(h3);
|
|
||||||
h1.waitForResponse();
|
|
||||||
assertThat(h1.getServletResponse().getStatus()).isEqualTo(429);
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void requestReceivedOutOfOrder() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection();
|
|
||||||
MockHttpConnection h2 = new MockHttpConnection("1+2", 1);
|
|
||||||
MockHttpConnection h3 = new MockHttpConnection("+3", 2);
|
|
||||||
this.server.handle(h1);
|
|
||||||
this.server.handle(h3);
|
|
||||||
this.server.handle(h2);
|
|
||||||
this.serverChannel.verifyReceived("1+2+3");
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpConnectionsAreClosedAfterLongPollTimeout() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.server.setDisconnectTimeout(1000);
|
|
||||||
this.server.setLongPollTimeout(100);
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection();
|
|
||||||
this.server.handle(h1);
|
|
||||||
Awaitility.await()
|
|
||||||
.atMost(Duration.ofSeconds(30))
|
|
||||||
.until(h1.getServletResponse()::getStatus, (status) -> status == 204);
|
|
||||||
MockHttpConnection h2 = new MockHttpConnection();
|
|
||||||
this.server.handle(h2);
|
|
||||||
Awaitility.await()
|
|
||||||
.atMost(Duration.ofSeconds(30))
|
|
||||||
.until(h2.getServletResponse()::getStatus, (status) -> status == 204);
|
|
||||||
this.serverChannel.disconnect();
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void disconnectTimeout() throws Exception {
|
|
||||||
givenServerConnectionOpenWillAnswerWithServerChannel();
|
|
||||||
this.server.setDisconnectTimeout(100);
|
|
||||||
this.server.setLongPollTimeout(100);
|
|
||||||
MockHttpConnection h1 = new MockHttpConnection();
|
|
||||||
this.server.handle(h1);
|
|
||||||
this.serverChannel.send("hello");
|
|
||||||
this.server.getServerThread().join(JOIN_TIMEOUT);
|
|
||||||
assertThat(this.serverChannel.isOpen()).isFalse();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void disconnectTimeoutMustBePositive() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> this.server.setDisconnectTimeout(0))
|
|
||||||
.withMessageContaining("DisconnectTimeout must be a positive value");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpConnectionRespondWithPayload() throws Exception {
|
|
||||||
HttpConnection connection = new HttpConnection(this.request, this.response);
|
|
||||||
connection.waitForResponse();
|
|
||||||
connection.respond(new HttpTunnelPayload(1, ByteBuffer.wrap("hello".getBytes())));
|
|
||||||
assertThat(this.servletResponse.getStatus()).isEqualTo(200);
|
|
||||||
assertThat(this.servletResponse.getContentAsString()).isEqualTo("hello");
|
|
||||||
assertThat(this.servletResponse.getHeader(SEQ_HEADER)).isEqualTo("1");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpConnectionRespondWithStatus() throws Exception {
|
|
||||||
HttpConnection connection = new HttpConnection(this.request, this.response);
|
|
||||||
connection.waitForResponse();
|
|
||||||
connection.respond(HttpStatus.I_AM_A_TEAPOT);
|
|
||||||
assertThat(this.servletResponse.getStatus()).isEqualTo(418);
|
|
||||||
assertThat(this.servletResponse.getContentLength()).isZero();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpConnectionAsync() throws Exception {
|
|
||||||
ServerHttpAsyncRequestControl async = mock(ServerHttpAsyncRequestControl.class);
|
|
||||||
ServerHttpRequest request = mock(ServerHttpRequest.class);
|
|
||||||
given(request.getAsyncRequestControl(this.response)).willReturn(async);
|
|
||||||
HttpConnection connection = new HttpConnection(request, this.response);
|
|
||||||
connection.waitForResponse();
|
|
||||||
then(async).should().start();
|
|
||||||
connection.respond(HttpStatus.NO_CONTENT);
|
|
||||||
then(async).should().complete();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpConnectionNonAsync() throws Exception {
|
|
||||||
testHttpConnectionNonAsync(0);
|
|
||||||
testHttpConnectionNonAsync(100);
|
|
||||||
}
|
|
||||||
|
|
||||||
private void testHttpConnectionNonAsync(long sleepBeforeResponse) throws IOException, InterruptedException {
|
|
||||||
ServerHttpRequest request = mock(ServerHttpRequest.class);
|
|
||||||
given(request.getAsyncRequestControl(this.response)).willThrow(new IllegalArgumentException());
|
|
||||||
final HttpConnection connection = new HttpConnection(request, this.response);
|
|
||||||
final AtomicBoolean responded = new AtomicBoolean();
|
|
||||||
Thread connectionThread = new Thread(() -> {
|
|
||||||
connection.waitForResponse();
|
|
||||||
responded.set(true);
|
|
||||||
});
|
|
||||||
connectionThread.start();
|
|
||||||
assertThat(responded.get()).isFalse();
|
|
||||||
Thread.sleep(sleepBeforeResponse);
|
|
||||||
connection.respond(HttpStatus.NO_CONTENT);
|
|
||||||
connectionThread.join();
|
|
||||||
assertThat(responded.get()).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void httpConnectionRunning() throws Exception {
|
|
||||||
HttpConnection connection = new HttpConnection(this.request, this.response);
|
|
||||||
assertThat(connection.isOlderThan(100)).isFalse();
|
|
||||||
Thread.sleep(200);
|
|
||||||
assertThat(connection.isOlderThan(100)).isTrue();
|
|
||||||
}
|
|
||||||
|
|
||||||
private void givenServerConnectionOpenWillAnswerWithServerChannel() throws IOException {
|
|
||||||
given(this.serverConnection.open(anyInt())).willAnswer((invocation) -> {
|
|
||||||
MockServerChannel channel = HttpTunnelServerTests.this.serverChannel;
|
|
||||||
channel.setTimeout(invocation.getArgument(0));
|
|
||||||
return channel;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock {@link ByteChannel} used to simulate the server connection.
|
|
||||||
*/
|
|
||||||
static class MockServerChannel implements ByteChannel {
|
|
||||||
|
|
||||||
private static final ByteBuffer DISCONNECT = ByteBuffer.wrap(NO_DATA);
|
|
||||||
|
|
||||||
private int timeout;
|
|
||||||
|
|
||||||
private final BlockingDeque<ByteBuffer> outgoing = new LinkedBlockingDeque<>();
|
|
||||||
|
|
||||||
private final ByteArrayOutputStream written = new ByteArrayOutputStream();
|
|
||||||
|
|
||||||
private final AtomicBoolean open = new AtomicBoolean(true);
|
|
||||||
|
|
||||||
void setTimeout(int timeout) {
|
|
||||||
this.timeout = timeout;
|
|
||||||
}
|
|
||||||
|
|
||||||
void send(String content) {
|
|
||||||
send(content.getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
void send(byte[] bytes) {
|
|
||||||
this.outgoing.addLast(ByteBuffer.wrap(bytes));
|
|
||||||
}
|
|
||||||
|
|
||||||
void disconnect() {
|
|
||||||
this.outgoing.addLast(DISCONNECT);
|
|
||||||
}
|
|
||||||
|
|
||||||
void verifyReceived(String expected) {
|
|
||||||
verifyReceived(expected.getBytes());
|
|
||||||
}
|
|
||||||
|
|
||||||
void verifyReceived(byte[] expected) {
|
|
||||||
synchronized (this.written) {
|
|
||||||
assertThat(this.written.toByteArray()).isEqualTo(expected);
|
|
||||||
this.written.reset();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int read(ByteBuffer dst) throws IOException {
|
|
||||||
try {
|
|
||||||
ByteBuffer bytes = this.outgoing.pollFirst(this.timeout, TimeUnit.MILLISECONDS);
|
|
||||||
if (bytes == null) {
|
|
||||||
throw new SocketTimeoutException();
|
|
||||||
}
|
|
||||||
if (bytes == DISCONNECT) {
|
|
||||||
this.open.set(false);
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
int initialRemaining = dst.remaining();
|
|
||||||
bytes.limit(Math.min(bytes.limit(), initialRemaining));
|
|
||||||
dst.put(bytes);
|
|
||||||
bytes.limit(bytes.capacity());
|
|
||||||
return initialRemaining - dst.remaining();
|
|
||||||
}
|
|
||||||
catch (InterruptedException ex) {
|
|
||||||
throw new IllegalStateException(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public int write(ByteBuffer src) throws IOException {
|
|
||||||
int remaining = src.remaining();
|
|
||||||
synchronized (this.written) {
|
|
||||||
Channels.newChannel(this.written).write(src);
|
|
||||||
}
|
|
||||||
return remaining;
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public boolean isOpen() {
|
|
||||||
return this.open.get();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void close() {
|
|
||||||
this.open.set(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Mock {@link HttpConnection}.
|
|
||||||
*/
|
|
||||||
static class MockHttpConnection extends HttpConnection {
|
|
||||||
|
|
||||||
MockHttpConnection() {
|
|
||||||
super(new ServletServerHttpRequest(new MockHttpServletRequest()),
|
|
||||||
new ServletServerHttpResponse(new MockHttpServletResponse()));
|
|
||||||
}
|
|
||||||
|
|
||||||
MockHttpConnection(String content, int seq) {
|
|
||||||
this();
|
|
||||||
MockHttpServletRequest request = getServletRequest();
|
|
||||||
request.setContent(content.getBytes());
|
|
||||||
request.addHeader(SEQ_HEADER, String.valueOf(seq));
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected ServerHttpAsyncRequestControl startAsync() {
|
|
||||||
getServletRequest().setAsyncSupported(true);
|
|
||||||
return super.startAsync();
|
|
||||||
}
|
|
||||||
|
|
||||||
@Override
|
|
||||||
protected void complete() {
|
|
||||||
super.complete();
|
|
||||||
getServletResponse().setCommitted(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
MockHttpServletRequest getServletRequest() {
|
|
||||||
return (MockHttpServletRequest) ((ServletServerHttpRequest) getRequest()).getServletRequest();
|
|
||||||
}
|
|
||||||
|
|
||||||
MockHttpServletResponse getServletResponse() {
|
|
||||||
return (MockHttpServletResponse) ((ServletServerHttpResponse) getResponse()).getServletResponse();
|
|
||||||
}
|
|
||||||
|
|
||||||
void verifyReceived(String expectedContent, int expectedSeq) throws Exception {
|
|
||||||
waitForServletResponse();
|
|
||||||
MockHttpServletResponse resp = getServletResponse();
|
|
||||||
assertThat(resp.getContentAsString()).isEqualTo(expectedContent);
|
|
||||||
assertThat(resp.getHeader(SEQ_HEADER)).isEqualTo(String.valueOf(expectedSeq));
|
|
||||||
}
|
|
||||||
|
|
||||||
void waitForServletResponse() throws InterruptedException {
|
|
||||||
while (!getServletResponse().isCommitted()) {
|
|
||||||
Thread.sleep(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,172 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import java.io.IOException;
|
|
||||||
import java.net.InetSocketAddress;
|
|
||||||
import java.net.SocketTimeoutException;
|
|
||||||
import java.nio.ByteBuffer;
|
|
||||||
import java.nio.channels.ByteChannel;
|
|
||||||
import java.nio.channels.ServerSocketChannel;
|
|
||||||
import java.nio.channels.SocketChannel;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatExceptionOfType;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link SocketTargetServerConnection}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class SocketTargetServerConnectionTests {
|
|
||||||
|
|
||||||
private static final int DEFAULT_TIMEOUT = 5000;
|
|
||||||
|
|
||||||
private MockServer server;
|
|
||||||
|
|
||||||
private SocketTargetServerConnection connection;
|
|
||||||
|
|
||||||
@BeforeEach
|
|
||||||
void setup() throws IOException {
|
|
||||||
this.server = new MockServer();
|
|
||||||
this.connection = new SocketTargetServerConnection(() -> this.server.getPort());
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void readData() throws Exception {
|
|
||||||
this.server.willSend("hello".getBytes());
|
|
||||||
this.server.start();
|
|
||||||
try (ByteChannel channel = this.connection.open(DEFAULT_TIMEOUT)) {
|
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(5);
|
|
||||||
channel.read(buffer);
|
|
||||||
assertThat(buffer.array()).isEqualTo("hello".getBytes());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void writeData() throws Exception {
|
|
||||||
this.server.expect("hello".getBytes());
|
|
||||||
this.server.start();
|
|
||||||
try (ByteChannel channel = this.connection.open(DEFAULT_TIMEOUT)) {
|
|
||||||
ByteBuffer buffer = ByteBuffer.wrap("hello".getBytes());
|
|
||||||
channel.write(buffer);
|
|
||||||
this.server.closeAndVerify();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void timeout() throws Exception {
|
|
||||||
this.server.delay(1000);
|
|
||||||
this.server.start();
|
|
||||||
try (ByteChannel channel = this.connection.open(10)) {
|
|
||||||
long startTime = System.currentTimeMillis();
|
|
||||||
assertThatExceptionOfType(SocketTimeoutException.class)
|
|
||||||
.isThrownBy(() -> channel.read(ByteBuffer.allocate(5)))
|
|
||||||
.satisfies((ex) -> {
|
|
||||||
long runTime = System.currentTimeMillis() - startTime;
|
|
||||||
assertThat(runTime).isGreaterThanOrEqualTo(10L);
|
|
||||||
assertThat(runTime).isLessThan(10000L);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static class MockServer {
|
|
||||||
|
|
||||||
private final ServerSocketChannel serverSocket;
|
|
||||||
|
|
||||||
private byte[] send;
|
|
||||||
|
|
||||||
private byte[] expect;
|
|
||||||
|
|
||||||
private int delay;
|
|
||||||
|
|
||||||
private ByteBuffer actualRead;
|
|
||||||
|
|
||||||
private ServerThread thread;
|
|
||||||
|
|
||||||
MockServer() throws IOException {
|
|
||||||
this.serverSocket = ServerSocketChannel.open();
|
|
||||||
this.serverSocket.bind(new InetSocketAddress(0));
|
|
||||||
}
|
|
||||||
|
|
||||||
int getPort() {
|
|
||||||
return this.serverSocket.socket().getLocalPort();
|
|
||||||
}
|
|
||||||
|
|
||||||
void delay(int delay) {
|
|
||||||
this.delay = delay;
|
|
||||||
}
|
|
||||||
|
|
||||||
void willSend(byte[] send) {
|
|
||||||
this.send = send;
|
|
||||||
}
|
|
||||||
|
|
||||||
void expect(byte[] expect) {
|
|
||||||
this.expect = expect;
|
|
||||||
}
|
|
||||||
|
|
||||||
void start() {
|
|
||||||
this.thread = new ServerThread();
|
|
||||||
this.thread.start();
|
|
||||||
}
|
|
||||||
|
|
||||||
void closeAndVerify() throws InterruptedException {
|
|
||||||
close();
|
|
||||||
assertThat(this.actualRead.array()).isEqualTo(this.expect);
|
|
||||||
}
|
|
||||||
|
|
||||||
void close() throws InterruptedException {
|
|
||||||
while (this.thread.isAlive()) {
|
|
||||||
Thread.sleep(10);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private class ServerThread extends Thread {
|
|
||||||
|
|
||||||
@Override
|
|
||||||
public void run() {
|
|
||||||
try {
|
|
||||||
SocketChannel channel = MockServer.this.serverSocket.accept();
|
|
||||||
Thread.sleep(MockServer.this.delay);
|
|
||||||
if (MockServer.this.send != null) {
|
|
||||||
ByteBuffer buffer = ByteBuffer.wrap(MockServer.this.send);
|
|
||||||
while (buffer.hasRemaining()) {
|
|
||||||
channel.write(buffer);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (MockServer.this.expect != null) {
|
|
||||||
ByteBuffer buffer = ByteBuffer.allocate(MockServer.this.expect.length);
|
|
||||||
while (buffer.hasRemaining()) {
|
|
||||||
channel.read(buffer);
|
|
||||||
}
|
|
||||||
MockServer.this.actualRead = buffer;
|
|
||||||
}
|
|
||||||
channel.close();
|
|
||||||
}
|
|
||||||
catch (Exception ex) {
|
|
||||||
throw new RuntimeException(ex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
@ -1,43 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright 2012-2023 the original author or authors.
|
|
||||||
*
|
|
||||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
* you may not use this file except in compliance with the License.
|
|
||||||
* You may obtain a copy of the License at
|
|
||||||
*
|
|
||||||
* https://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
*
|
|
||||||
* Unless required by applicable law or agreed to in writing, software
|
|
||||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
* See the License for the specific language governing permissions and
|
|
||||||
* limitations under the License.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package org.springframework.boot.devtools.tunnel.server;
|
|
||||||
|
|
||||||
import org.junit.jupiter.api.Test;
|
|
||||||
|
|
||||||
import static org.assertj.core.api.Assertions.assertThat;
|
|
||||||
import static org.assertj.core.api.Assertions.assertThatIllegalArgumentException;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Tests for {@link StaticPortProvider}.
|
|
||||||
*
|
|
||||||
* @author Phillip Webb
|
|
||||||
*/
|
|
||||||
class StaticPortProviderTests {
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void portMustBePositive() {
|
|
||||||
assertThatIllegalArgumentException().isThrownBy(() -> new StaticPortProvider(0))
|
|
||||||
.withMessageContaining("Port must be positive");
|
|
||||||
}
|
|
||||||
|
|
||||||
@Test
|
|
||||||
void getPort() {
|
|
||||||
StaticPortProvider provider = new StaticPortProvider(123);
|
|
||||||
assertThat(provider.getPort()).isEqualTo(123);
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
Loading…
Reference in New Issue
Block a user