26 changed files with 0 additions and 3186 deletions
@ -1,219 +0,0 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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