Add remote debug tunnel auto-configuration

Provide auto-configuration for remote debugging over a HTTP tunnel.
The RemoteClientConfiguration provides a server that the local IDE can
connect to. When a client connects the remote connection is established
using the HTTP tunnel.

See gh-3087
pull/3077/merge
Phillip Webb 10 years ago
parent 2123b267aa
commit bdf7663a9a

@ -23,6 +23,7 @@ import javax.servlet.Filter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.autoconfigure.EnableAutoConfiguration;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
@ -39,6 +40,10 @@ import org.springframework.boot.developertools.restart.server.DefaultSourceFolde
import org.springframework.boot.developertools.restart.server.HttpRestartServer;
import org.springframework.boot.developertools.restart.server.HttpRestartServerHandler;
import org.springframework.boot.developertools.restart.server.SourceFolderUrlFilter;
import org.springframework.boot.developertools.tunnel.server.HttpTunnelServer;
import org.springframework.boot.developertools.tunnel.server.HttpTunnelServerHandler;
import org.springframework.boot.developertools.tunnel.server.RemoteDebugPortProvider;
import org.springframework.boot.developertools.tunnel.server.SocketTargetServerConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.server.ServerHttpRequest;
@ -109,4 +114,32 @@ public class RemoteDeveloperToolsAutoConfiguration {
}
/**
* Configuration for remote debug HTTP tunneling.
*/
@ConditionalOnProperty(prefix = "spring.developertools.remote.debug", name = "enabled", matchIfMissing = true)
static class RemoteDebugTunnelConfiguration {
@Autowired
private DeveloperToolsProperties properties;
@Bean
@ConditionalOnMissingBean(name = "remoteDebugHanderMapper")
public UrlHandlerMapper remoteDebugHanderMapper(
@Qualifier("remoteDebugHttpTunnelServer") HttpTunnelServer server) {
String url = this.properties.getRemote().getContextPath() + "/debug";
logger.warn("Listening for remote debug traffic on " + url);
Handler handler = new HttpTunnelServerHandler(server);
return new UrlHandlerMapper(url, handler);
}
@Bean
@ConditionalOnMissingBean(name = "remoteDebugHttpTunnelServer")
public HttpTunnelServer remoteDebugHttpTunnelServer() {
return new HttpTunnelServer(new SocketTargetServerConnection(
new RemoteDebugPortProvider()));
}
}
}

@ -35,6 +35,8 @@ public class RemoteDeveloperToolsProperties {
private Restart restart = new Restart();
private Debug debug = new Debug();
public String getContextPath() {
return this.contextPath;
}
@ -47,6 +49,10 @@ public class RemoteDeveloperToolsProperties {
return this.restart;
}
public Debug getDebug() {
return this.debug;
}
public static class Restart {
/**
@ -64,4 +70,36 @@ public class RemoteDeveloperToolsProperties {
}
public static class Debug {
public static final Integer DEFAULT_LOCAL_PORT = 8000;
/**
* Enable remote debug support.
*/
private boolean enabled = true;
/**
* Local remote debug server port.
*/
private int localPort = DEFAULT_LOCAL_PORT;
public boolean isEnabled() {
return this.enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
public int getLocalPort() {
return this.localPort;
}
public void setLocalPort(int localPort) {
this.localPort = localPort;
}
}
}

@ -0,0 +1,58 @@
/*
* Copyright 2012-2015 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
*
* http://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.developertools.remote.client;
import javax.net.ServerSocketFactory;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.autoconfigure.condition.SpringBootCondition;
import org.springframework.boot.bind.RelaxedPropertyResolver;
import org.springframework.boot.developertools.autoconfigure.RemoteDeveloperToolsProperties;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.core.type.AnnotatedTypeMetadata;
/**
* Condition used to check that the actual local port is available.
*/
class LocalDebugPortAvailableCondition extends SpringBootCondition {
@Override
public ConditionOutcome getMatchOutcome(ConditionContext context,
AnnotatedTypeMetadata metadata) {
RelaxedPropertyResolver resolver = new RelaxedPropertyResolver(
context.getEnvironment(), "spring.developertools.remote.debug.");
Integer port = resolver.getProperty("local-port", Integer.class);
if (port == null) {
port = RemoteDeveloperToolsProperties.Debug.DEFAULT_LOCAL_PORT;
}
if (isPortAvailable(port)) {
return ConditionOutcome.match("Local debug port availble");
}
return ConditionOutcome.noMatch("Local debug port unavailble");
}
private boolean isPortAvailable(int port) {
try {
ServerSocketFactory.getDefault().createServerSocket(port).close();
return true;
}
catch (Exception ex) {
return false;
}
}
}

@ -0,0 +1,45 @@
/*
* Copyright 2012-2015 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
*
* http://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.developertools.remote.client;
import java.nio.channels.SocketChannel;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.boot.developertools.tunnel.client.TunnelClientListener;
/**
* {@link TunnelClientListener} to log open/close events.
*
* @author Phillip Webb
*/
class LoggingTunnelClientListener implements TunnelClientListener {
private static final Log logger = LogFactory
.getLog(LoggingTunnelClientListener.class);
@Override
public void onOpen(SocketChannel socket) {
logger.info("Remote debug connection opened");
}
@Override
public void onClose(SocketChannel socket) {
logger.info("Remote debug connection closed");
}
}

@ -21,11 +21,13 @@ import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import javax.annotation.PostConstruct;
import javax.servlet.Filter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@ -40,7 +42,11 @@ import org.springframework.boot.developertools.livereload.LiveReloadServer;
import org.springframework.boot.developertools.restart.DefaultRestartInitializer;
import org.springframework.boot.developertools.restart.RestartScope;
import org.springframework.boot.developertools.restart.Restarter;
import org.springframework.boot.developertools.tunnel.client.HttpTunnelConnection;
import org.springframework.boot.developertools.tunnel.client.TunnelClient;
import org.springframework.boot.developertools.tunnel.client.TunnelConnection;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Conditional;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.event.EventListener;
import org.springframework.context.support.PropertySourcesPlaceholderConfigurer;
@ -79,8 +85,9 @@ public class RemoteClientConfiguration {
@PostConstruct
private void logWarnings() {
RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote();
if (!remoteProperties.getRestart().isEnabled()) {
logger.warn("Remote restart is not enabled.");
if (!remoteProperties.getDebug().isEnabled()
&& !remoteProperties.getRestart().isEnabled()) {
logger.warn("Remote restart and debug are both disabled.");
}
}
@ -168,4 +175,32 @@ public class RemoteClientConfiguration {
}
/**
* Client configuration for remote debug HTTP tunneling.
*/
@ConditionalOnProperty(prefix = "spring.developertools.remote.debug", name = "enabled", matchIfMissing = true)
@ConditionalOnClass(Filter.class)
@Conditional(LocalDebugPortAvailableCondition.class)
static class RemoteDebugTunnelClientConfiguration {
@Autowired
private DeveloperToolsProperties properties;
@Value("${remoteUrl}")
private String remoteUrl;
@Bean
public TunnelClient remoteDebugTunnelClient(
ClientHttpRequestFactory requestFactory) {
RemoteDeveloperToolsProperties remoteProperties = this.properties.getRemote();
String url = this.remoteUrl + remoteProperties.getContextPath() + "/debug";
TunnelConnection connection = new HttpTunnelConnection(url, requestFactory);
int localPort = remoteProperties.getDebug().getLocalPort();
TunnelClient client = new TunnelClient(localPort, connection);
client.addListener(new LoggingTunnelClientListener());
return client;
}
}
}

@ -30,6 +30,10 @@ import org.springframework.boot.developertools.remote.server.DispatcherFilter;
import org.springframework.boot.developertools.restart.MockRestarter;
import org.springframework.boot.developertools.restart.server.HttpRestartServer;
import org.springframework.boot.developertools.restart.server.SourceFolderUrlFilter;
import org.springframework.boot.developertools.tunnel.server.HttpTunnelServer;
import org.springframework.boot.developertools.tunnel.server.RemoteDebugPortProvider;
import org.springframework.boot.developertools.tunnel.server.SocketTargetServerConnection;
import org.springframework.boot.developertools.tunnel.server.TargetServerConnection;
import org.springframework.boot.test.EnvironmentTestUtils;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@ -110,6 +114,23 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
this.context.getBean("remoteRestartHanderMapper");
}
@Test
public void invokeTunnelWithDefaultSetup() throws Exception {
loadContext("spring.developertools.remote.enabled:true");
DispatcherFilter filter = this.context.getBean(DispatcherFilter.class);
this.request.setRequestURI(DEFAULT_CONTEXT_PATH + "/debug");
filter.doFilter(this.request, this.response, this.chain);
assertTunnelInvoked(true);
}
@Test
public void disableRemoteDebug() throws Exception {
loadContext("spring.developertools.remote.enabled:true",
"spring.developertools.remote.debug.enabled:false");
this.thrown.expect(NoSuchBeanDefinitionException.class);
this.context.getBean("remoteDebugHanderMapper");
}
@Test
public void developerToolsHealthReturns200() throws Exception {
loadContext("spring.developertools.remote.enabled:true");
@ -120,6 +141,11 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
assertThat(this.response.getStatus(), equalTo(200));
}
private void assertTunnelInvoked(boolean value) {
assertThat(this.context.getBean(MockHttpTunnelServer.class).invoked,
equalTo(value));
}
private void assertRestartInvoked(boolean value) {
assertThat(this.context.getBean(MockHttpRestartServer.class).invoked,
equalTo(value));
@ -138,6 +164,12 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
@Import(RemoteDeveloperToolsAutoConfiguration.class)
static class Config {
@Bean
public HttpTunnelServer remoteDebugHttpTunnelServer() {
return new MockHttpTunnelServer(new SocketTargetServerConnection(
new RemoteDebugPortProvider()));
}
@Bean
public HttpRestartServer remoteRestartHttpRestartServer() {
SourceFolderUrlFilter sourceFolderUrlFilter = mock(SourceFolderUrlFilter.class);
@ -146,6 +178,25 @@ public class RemoteDeveloperToolsAutoConfigurationTests {
}
/**
* Mock {@link HttpTunnelServer} implementation.
*/
static class MockHttpTunnelServer extends HttpTunnelServer {
private boolean invoked;
public MockHttpTunnelServer(TargetServerConnection serverConnection) {
super(serverConnection);
}
@Override
public void handle(ServerHttpRequest request, ServerHttpResponse response)
throws IOException {
this.invoked = true;
}
}
/**
* Mock {@link HttpRestartServer} implementation.
*/

@ -0,0 +1,144 @@
/*
* Copyright 2012-2015 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
*
* http://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.developertools.integrationtest;
import java.util.Collection;
import java.util.Collections;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.context.embedded.EmbeddedServletContainerFactory;
import org.springframework.boot.context.embedded.tomcat.TomcatEmbeddedServletContainerFactory;
import org.springframework.boot.developertools.remote.server.AccessManager;
import org.springframework.boot.developertools.remote.server.Dispatcher;
import org.springframework.boot.developertools.remote.server.DispatcherFilter;
import org.springframework.boot.developertools.remote.server.HandlerMapper;
import org.springframework.boot.developertools.remote.server.UrlHandlerMapper;
import org.springframework.boot.developertools.tunnel.client.HttpTunnelConnection;
import org.springframework.boot.developertools.tunnel.client.TunnelClient;
import org.springframework.boot.developertools.tunnel.client.TunnelConnection;
import org.springframework.boot.developertools.tunnel.server.HttpTunnelServer;
import org.springframework.boot.developertools.tunnel.server.HttpTunnelServerHandler;
import org.springframework.boot.developertools.tunnel.server.PortProvider;
import org.springframework.boot.developertools.tunnel.server.SocketTargetServerConnection;
import org.springframework.boot.developertools.tunnel.server.StaticPortProvider;
import org.springframework.boot.developertools.tunnel.server.TargetServerConnection;
import org.springframework.boot.test.SpringApplicationConfiguration;
import org.springframework.boot.test.TestRestTemplate;
import org.springframework.boot.test.WebIntegrationTest;
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.test.context.junit4.SpringJUnit4ClassRunner;
import org.springframework.util.SocketUtils;
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.junit.Assert.assertEquals;
/**
* Simple integration tests for HTTP tunneling.
*
* @author Phillip Webb
*/
@RunWith(SpringJUnit4ClassRunner.class)
@SpringApplicationConfiguration(classes = HttpTunnelIntegrationTest.Config.class)
@WebIntegrationTest
public class HttpTunnelIntegrationTest {
@Autowired
private Config config;
@Test
public void httpServerDirect() throws Exception {
String url = "http://localhost:" + this.config.httpServerPort + "/hello";
ResponseEntity<String> entity = new TestRestTemplate().getForEntity(url,
String.class);
assertEquals(HttpStatus.OK, entity.getStatusCode());
assertEquals("Hello World", entity.getBody());
}
@Test
public void viaTunnel() throws Exception {
String url = "http://localhost:" + this.config.clientPort + "/hello";
ResponseEntity<String> entity = new TestRestTemplate().getForEntity(url,
String.class);
assertEquals(HttpStatus.OK, entity.getStatusCode());
assertEquals("Hello World", entity.getBody());
}
@Configuration
@EnableWebMvc
static class Config {
private int clientPort = SocketUtils.findAvailableTcpPort();
private int httpServerPort = SocketUtils.findAvailableTcpPort();
@Bean
public EmbeddedServletContainerFactory container() {
return new TomcatEmbeddedServletContainerFactory(this.httpServerPort);
}
@Bean
public DispatcherFilter filter() {
PortProvider port = new StaticPortProvider(this.httpServerPort);
TargetServerConnection connection = new SocketTargetServerConnection(port);
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);
}
@Bean
public TunnelClient tunnelClient() {
String url = "http://localhost:" + this.httpServerPort + "/httptunnel";
TunnelConnection connection = new HttpTunnelConnection(url,
new SimpleClientHttpRequestFactory());
return new TunnelClient(this.clientPort, connection);
}
@Bean
public DispatcherServlet dispatcherServlet() {
return new DispatcherServlet();
}
@Bean
public MyController myController() {
return new MyController();
}
}
@RestController
static class MyController {
@RequestMapping("/hello")
public String hello() {
return "Hello World";
}
}
}

@ -0,0 +1,73 @@
/*
* Copyright 2012-2015 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
*
* http://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.developertools.remote.client;
import java.net.ServerSocket;
import javax.net.ServerSocketFactory;
import org.junit.Test;
import org.springframework.boot.autoconfigure.condition.ConditionOutcome;
import org.springframework.boot.test.EnvironmentTestUtils;
import org.springframework.context.annotation.ConditionContext;
import org.springframework.mock.env.MockEnvironment;
import org.springframework.util.SocketUtils;
import static org.hamcrest.Matchers.equalTo;
import static org.junit.Assert.assertThat;
import static org.mockito.BDDMockito.given;
import static org.mockito.Mockito.mock;
/**
* Tests for {@link LocalDebugPortAvailableCondition}.
*
* @author Phillip Webb
*/
public class LocalDebugPortAvailableConditionTests {
private int port = SocketUtils.findAvailableTcpPort();
private LocalDebugPortAvailableCondition condition = new LocalDebugPortAvailableCondition();
@Test
public void portAvailable() throws Exception {
ConditionOutcome outcome = getOutcome();
assertThat(outcome.isMatch(), equalTo(true));
assertThat(outcome.getMessage(), equalTo("Local debug port availble"));
}
@Test
public void portInUse() throws Exception {
final ServerSocket serverSocket = ServerSocketFactory.getDefault()
.createServerSocket(this.port);
ConditionOutcome outcome = getOutcome();
serverSocket.close();
assertThat(outcome.isMatch(), equalTo(false));
assertThat(outcome.getMessage(), equalTo("Local debug port unavailble"));
}
private ConditionOutcome getOutcome() {
MockEnvironment environment = new MockEnvironment();
EnvironmentTestUtils.addEnvironment(environment,
"spring.developertools.remote.debug.local-port:" + this.port);
ConditionContext context = mock(ConditionContext.class);
given(context.getEnvironment()).willReturn(environment);
ConditionOutcome outcome = this.condition.getMatchOutcome(context, null);
return outcome;
}
}

@ -38,6 +38,7 @@ import org.springframework.boot.developertools.remote.server.Dispatcher;
import org.springframework.boot.developertools.remote.server.DispatcherFilter;
import org.springframework.boot.developertools.restart.MockRestarter;
import org.springframework.boot.developertools.restart.RestartScopeInitializer;
import org.springframework.boot.developertools.tunnel.client.TunnelClient;
import org.springframework.boot.test.EnvironmentTestUtils;
import org.springframework.boot.test.OutputCapture;
import org.springframework.context.annotation.Bean;
@ -82,10 +83,11 @@ public class RemoteClientConfigurationTests {
}
@Test
public void warnIfRestartDisabled() throws Exception {
configure("spring.developertools.remote.restart.enabled:false");
public void warnIfDebugAndRestartDisabled() throws Exception {
configure("spring.developertools.remote.debug.enabled:false",
"spring.developertools.remote.restart.enabled:false");
assertThat(this.output.toString(),
containsString("Remote restart is not enabled"));
containsString("Remote restart and debug are both disabled"));
}
@Test
@ -122,6 +124,13 @@ public class RemoteClientConfigurationTests {
this.context.getBean(ClassPathFileSystemWatcher.class);
}
@Test
public void remoteDebugDisabled() throws Exception {
configure("spring.developertools.remote.debug.enabled:false");
this.thrown.expect(NoSuchBeanDefinitionException.class);
this.context.getBean(TunnelClient.class);
}
private void configure(String... pairs) {
configureWithRemoteUrl("http://localhost", pairs);
}

Loading…
Cancel
Save