From 2aba8e1113a4d57050c7d3626ae132d6d09154f2 Mon Sep 17 00:00:00 2001 From: myProjectsRavi <95069115+myProjectsRavi@users.noreply.github.com> Date: Sun, 4 Oct 2026 10:24:10 +0530 Subject: [PATCH 1/2] feat: support Spring Cloud InetUtils Use conditional servlet and reactive factories for host resolution. Keep Spring Cloud optional and preserve existing URL handling. Closes #1323. --- spring-boot-admin-client/pom.xml | 5 + ...AdminClientInetUtilsAutoConfiguration.java | 97 +++++++ .../InetUtilsReactiveApplicationFactory.java | 48 ++++ .../InetUtilsServletApplicationFactory.java | 51 ++++ ...ot.autoconfigure.AutoConfiguration.imports | 1 + ...nClientInetUtilsAutoConfigurationTest.java | 230 ++++++++++++++++ .../InetUtilsApplicationFactoryTest.java | 249 ++++++++++++++++++ .../site/docs/03-client/20-registration.md | 27 ++ 8 files changed, 708 insertions(+) create mode 100644 spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfiguration.java create mode 100644 spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsReactiveApplicationFactory.java create mode 100644 spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsServletApplicationFactory.java create mode 100644 spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfigurationTest.java create mode 100644 spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/registration/InetUtilsApplicationFactoryTest.java diff --git a/spring-boot-admin-client/pom.xml b/spring-boot-admin-client/pom.xml index ead3f1533f8..2c5991703a1 100644 --- a/spring-boot-admin-client/pom.xml +++ b/spring-boot-admin-client/pom.xml @@ -54,6 +54,11 @@ spring-boot-starter-webmvc true + + org.springframework.cloud + spring-cloud-commons + true + org.springframework.boot spring-boot-autoconfigure-processor diff --git a/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfiguration.java b/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfiguration.java new file mode 100644 index 00000000000..50e95b9be2a --- /dev/null +++ b/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfiguration.java @@ -0,0 +1,97 @@ +/* + * Copyright 2014-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 de.codecentric.boot.admin.client.config; + +import java.util.Collections; +import java.util.List; + +import jakarta.servlet.ServletContext; +import org.springframework.beans.factory.ObjectProvider; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; +import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; +import org.springframework.boot.autoconfigure.AutoConfigureAfter; +import org.springframework.boot.autoconfigure.AutoConfigureBefore; +import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnClass; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication; +import org.springframework.boot.autoconfigure.condition.ConditionalOnWebApplication.Type; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; +import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; +import org.springframework.boot.webmvc.autoconfigure.DispatcherServletPath; +import org.springframework.cloud.commons.util.InetUtils; +import org.springframework.cloud.commons.util.UtilAutoConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Conditional; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Lazy; + +import de.codecentric.boot.admin.client.registration.ApplicationFactory; +import de.codecentric.boot.admin.client.registration.InetUtilsReactiveApplicationFactory; +import de.codecentric.boot.admin.client.registration.InetUtilsServletApplicationFactory; +import de.codecentric.boot.admin.client.registration.metadata.CompositeMetadataContributor; +import de.codecentric.boot.admin.client.registration.metadata.MetadataContributor; + +@Configuration(proxyBeanMethods = false) +@ConditionalOnWebApplication +@ConditionalOnClass(InetUtils.class) +@ConditionalOnBean(InetUtils.class) +@Conditional(SpringBootAdminClientEnabledCondition.class) +@AutoConfigureAfter({ UtilAutoConfiguration.class, SpringBootAdminClientCloudFoundryAutoConfiguration.class }) +@AutoConfigureBefore(SpringBootAdminClientAutoConfiguration.class) +public class SpringBootAdminClientInetUtilsAutoConfiguration { + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = Type.SERVLET) + public static class ServletConfiguration { + + @Bean + @Lazy(false) + @ConditionalOnMissingBean(ApplicationFactory.class) + public ApplicationFactory applicationFactory(InstanceProperties instance, ManagementServerProperties management, + ServerProperties server, ServletContext servletContext, PathMappedEndpoints pathMappedEndpoints, + WebEndpointProperties webEndpoint, ObjectProvider> metadataContributors, + DispatcherServletPath dispatcherServletPath, InetUtils inetUtils) { + return new InetUtilsServletApplicationFactory(instance, management, server, servletContext, + pathMappedEndpoints, webEndpoint, + new CompositeMetadataContributor(metadataContributors.getIfAvailable(Collections::emptyList)), + dispatcherServletPath, inetUtils); + } + + } + + @Configuration(proxyBeanMethods = false) + @ConditionalOnWebApplication(type = Type.REACTIVE) + public static class ReactiveConfiguration { + + @Bean + @Lazy(false) + @ConditionalOnMissingBean(ApplicationFactory.class) + public ApplicationFactory applicationFactory(InstanceProperties instance, ManagementServerProperties management, + ServerProperties server, PathMappedEndpoints pathMappedEndpoints, WebEndpointProperties webEndpoint, + ObjectProvider> metadataContributors, WebFluxProperties webFluxProperties, + InetUtils inetUtils) { + return new InetUtilsReactiveApplicationFactory(instance, management, server, pathMappedEndpoints, + webEndpoint, + new CompositeMetadataContributor(metadataContributors.getIfAvailable(Collections::emptyList)), + webFluxProperties, inetUtils); + } + + } + +} diff --git a/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsReactiveApplicationFactory.java b/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsReactiveApplicationFactory.java new file mode 100644 index 00000000000..42b84b4d956 --- /dev/null +++ b/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsReactiveApplicationFactory.java @@ -0,0 +1,48 @@ +/* + * Copyright 2014-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 de.codecentric.boot.admin.client.registration; + +import java.net.InetAddress; + +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; +import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; +import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; +import org.springframework.cloud.commons.util.InetUtils; + +import de.codecentric.boot.admin.client.config.InstanceProperties; +import de.codecentric.boot.admin.client.registration.metadata.MetadataContributor; + +public class InetUtilsReactiveApplicationFactory extends ReactiveApplicationFactory { + + private final InetUtils inetUtils; + + public InetUtilsReactiveApplicationFactory(InstanceProperties instance, ManagementServerProperties management, + ServerProperties server, PathMappedEndpoints pathMappedEndpoints, WebEndpointProperties webEndpoint, + MetadataContributor metadataContributor, WebFluxProperties webFluxProperties, InetUtils inetUtils) { + super(instance, management, server, pathMappedEndpoints, webEndpoint, metadataContributor, webFluxProperties); + this.inetUtils = inetUtils; + } + + @Override + protected InetAddress getLocalHost() { + InetAddress address = this.inetUtils.findFirstNonLoopbackAddress(); + return (address != null) ? address : super.getLocalHost(); + } + +} diff --git a/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsServletApplicationFactory.java b/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsServletApplicationFactory.java new file mode 100644 index 00000000000..9212a89d1fb --- /dev/null +++ b/spring-boot-admin-client/src/main/java/de/codecentric/boot/admin/client/registration/InetUtilsServletApplicationFactory.java @@ -0,0 +1,51 @@ +/* + * Copyright 2014-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 de.codecentric.boot.admin.client.registration; + +import java.net.InetAddress; + +import jakarta.servlet.ServletContext; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; +import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; +import org.springframework.boot.webmvc.autoconfigure.DispatcherServletPath; +import org.springframework.cloud.commons.util.InetUtils; + +import de.codecentric.boot.admin.client.config.InstanceProperties; +import de.codecentric.boot.admin.client.registration.metadata.MetadataContributor; + +public class InetUtilsServletApplicationFactory extends ServletApplicationFactory { + + private final InetUtils inetUtils; + + public InetUtilsServletApplicationFactory(InstanceProperties instance, ManagementServerProperties management, + ServerProperties server, ServletContext servletContext, PathMappedEndpoints pathMappedEndpoints, + WebEndpointProperties webEndpoint, MetadataContributor metadataContributor, + DispatcherServletPath dispatcherServletPath, InetUtils inetUtils) { + super(instance, management, server, servletContext, pathMappedEndpoints, webEndpoint, metadataContributor, + dispatcherServletPath); + this.inetUtils = inetUtils; + } + + @Override + protected InetAddress getLocalHost() { + InetAddress address = this.inetUtils.findFirstNonLoopbackAddress(); + return (address != null) ? address : super.getLocalHost(); + } + +} diff --git a/spring-boot-admin-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports b/spring-boot-admin-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports index d9e84cbb914..b2542433380 100644 --- a/spring-boot-admin-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports +++ b/spring-boot-admin-client/src/main/resources/META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports @@ -1,3 +1,4 @@ de.codecentric.boot.admin.client.config.SpringBootAdminClientAutoConfiguration de.codecentric.boot.admin.client.config.SpringBootAdminClientCloudFoundryAutoConfiguration +de.codecentric.boot.admin.client.config.SpringBootAdminClientInetUtilsAutoConfiguration de.codecentric.boot.admin.client.config.SpringNativeClientAutoConfiguration diff --git a/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfigurationTest.java b/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfigurationTest.java new file mode 100644 index 00000000000..ad21cca0275 --- /dev/null +++ b/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/config/SpringBootAdminClientInetUtilsAutoConfigurationTest.java @@ -0,0 +1,230 @@ +/* + * Copyright 2014-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 de.codecentric.boot.admin.client.config; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Map; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.actuate.autoconfigure.endpoint.EndpointAutoConfiguration; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointAutoConfiguration; +import org.springframework.boot.autoconfigure.AutoConfigurations; +import org.springframework.boot.http.client.autoconfigure.HttpClientAutoConfiguration; +import org.springframework.boot.restclient.autoconfigure.RestClientAutoConfiguration; +import org.springframework.boot.test.context.FilteredClassLoader; +import org.springframework.boot.test.context.runner.AbstractApplicationContextRunner; +import org.springframework.boot.test.context.runner.ApplicationContextRunner; +import org.springframework.boot.test.context.runner.ReactiveWebApplicationContextRunner; +import org.springframework.boot.test.context.runner.WebApplicationContextRunner; +import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.boot.web.server.context.WebServerInitializedEvent; +import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; +import org.springframework.boot.webmvc.autoconfigure.DispatcherServletAutoConfiguration; +import org.springframework.cloud.commons.util.InetUtils; +import org.springframework.cloud.commons.util.InetUtilsProperties; +import org.springframework.cloud.commons.util.UtilAutoConfiguration; + +import de.codecentric.boot.admin.client.registration.ApplicationFactory; +import de.codecentric.boot.admin.client.registration.CloudFoundryApplicationFactory; +import de.codecentric.boot.admin.client.registration.DefaultApplicationFactory; +import de.codecentric.boot.admin.client.registration.InetUtilsReactiveApplicationFactory; +import de.codecentric.boot.admin.client.registration.InetUtilsServletApplicationFactory; +import de.codecentric.boot.admin.client.registration.ReactiveApplicationFactory; +import de.codecentric.boot.admin.client.registration.ServletApplicationFactory; +import de.codecentric.boot.admin.client.registration.metadata.MetadataContributor; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +class SpringBootAdminClientInetUtilsAutoConfigurationTest { + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void without_spring_cloud(WebApplicationType type) { + contextRunner(type).withClassLoader(new FilteredClassLoader("org.springframework.cloud")).run((context) -> { + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)).isExactlyInstanceOf(defaultFactoryType(type)); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void without_inetutils_bean(WebApplicationType type) { + contextRunner(type).run((context) -> { + assertThat(context).doesNotHaveBean(InetUtils.class); + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)).isExactlyInstanceOf(defaultFactoryType(type)); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void with_inetutils_bean(WebApplicationType type) { + contextRunner(type).withBean(InetUtils.class, () -> mock(InetUtils.class)).run((context) -> { + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)).isExactlyInstanceOf(inetUtilsFactoryType(type)); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void with_spring_cloud_auto_configuration(WebApplicationType type) { + contextRunner(type).withConfiguration(AutoConfigurations.of(UtilAutoConfiguration.class)) + .withPropertyValues("spring.cloud.inetutils.preferred-networks[0]=192.168.", + "spring.cloud.inetutils.ignored-interfaces[0]=docker.*") + .run((context) -> { + assertThat(context).hasSingleBean(InetUtils.class); + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)).isExactlyInstanceOf(inetUtilsFactoryType(type)); + InetUtilsProperties properties = context.getBean(InetUtilsProperties.class); + assertThat(properties.getPreferredNetworks()).containsExactly("192.168."); + assertThat(properties.getIgnoredInterfaces()).containsExactly("docker.*"); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void with_spring_cloud_util_disabled(WebApplicationType type) { + contextRunner(type).withConfiguration(AutoConfigurations.of(UtilAutoConfiguration.class)) + .withPropertyValues("spring.cloud.util.enabled=false") + .run((context) -> { + assertThat(context).doesNotHaveBean(InetUtils.class); + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)).isExactlyInstanceOf(defaultFactoryType(type)); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void disabled_client(WebApplicationType type) { + contextRunner(type).withBean(InetUtils.class, () -> mock(InetUtils.class)) + .withPropertyValues("spring.boot.admin.client.enabled=false") + .run((context) -> assertThat(context).doesNotHaveBean(ApplicationFactory.class)); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void unconfigured_client(WebApplicationType type) { + contextRunner(type).withBean(InetUtils.class, () -> mock(InetUtils.class)) + .withPropertyValues("spring.boot.admin.client.url=") + .run((context) -> assertThat(context).doesNotHaveBean(ApplicationFactory.class)); + } + + @Test + void non_web_application() { + new ApplicationContextRunner() + .withConfiguration(AutoConfigurations.of(SpringBootAdminClientAutoConfiguration.class, + SpringBootAdminClientInetUtilsAutoConfiguration.class, UtilAutoConfiguration.class)) + .withPropertyValues("spring.boot.admin.client.url=http://localhost:8081") + .run((context) -> assertThat(context).doesNotHaveBean(ApplicationFactory.class)); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void custom_factory_takes_precedence(WebApplicationType type) { + ApplicationFactory factory = mock(ApplicationFactory.class); + contextRunner(type).withBean(InetUtils.class, () -> mock(InetUtils.class)) + .withBean(ApplicationFactory.class, () -> factory) + .run((context) -> { + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)).isSameAs(factory); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void cloud_foundry_takes_precedence(WebApplicationType type) { + contextRunner(type).withBean(InetUtils.class, () -> mock(InetUtils.class)) + .withPropertyValues("VCAP_APPLICATION:{}") + .run((context) -> { + assertThat(context).hasSingleBean(ApplicationFactory.class); + assertThat(context.getBean(ApplicationFactory.class)) + .isExactlyInstanceOf(CloudFoundryApplicationFactory.class); + }); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_metadata_contributors(WebApplicationType type) { + contextRunner(type).withBean(InetUtils.class, () -> mock(InetUtils.class)) + .withBean(MetadataContributor.class, () -> () -> Map.of("custom", "contributor", "component", "test")) + .withPropertyValues("spring.boot.admin.client.instance.service-url=https://service.example", + "spring.boot.admin.client.instance.management-url=https://management.example", + "spring.boot.admin.client.instance.health-url=https://health.example", + "spring.boot.admin.client.instance.metadata.custom=instance") + .run((context) -> assertThat(context.getBean(ApplicationFactory.class).createApplication().getMetadata()) + .containsEntry("component", "test") + .containsEntry("custom", "instance")); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void uses_primary_inetutils(WebApplicationType type) throws UnknownHostException { + InetUtils primary = mock(InetUtils.class); + when(primary.findFirstNonLoopbackAddress()) + .thenReturn(InetAddress.getByAddress(new byte[] { (byte) 192, 0, 2, 20 })); + contextRunner(type).withBean("secondaryInetUtils", InetUtils.class, () -> mock(InetUtils.class)) + .withBean("primaryInetUtils", InetUtils.class, () -> primary, (definition) -> definition.setPrimary(true)) + .withPropertyValues("spring.boot.admin.client.instance.service-host-type=IP", + "spring.boot.admin.client.instance.health-url=http://localhost:8080/actuator/health") + .run((context) -> { + DefaultApplicationFactory factory = context.getBean(DefaultApplicationFactory.class); + WebServerInitializedEvent event = mock(WebServerInitializedEvent.class); + WebServerApplicationContext applicationContext = mock(WebServerApplicationContext.class); + WebServer webServer = mock(WebServer.class); + when(applicationContext.getServerNamespace()).thenReturn("server"); + when(webServer.getPort()).thenReturn(8080); + when(event.getApplicationContext()).thenReturn(applicationContext); + when(event.getWebServer()).thenReturn(webServer); + factory.onWebServerInitialized(event); + assertThat(factory.createApplication().getServiceUrl()).isEqualTo("http://192.0.2.20:8080/"); + }); + } + + private AbstractApplicationContextRunner contextRunner(WebApplicationType type) { + AutoConfigurations configurations = AutoConfigurations.of(EndpointAutoConfiguration.class, + WebEndpointAutoConfiguration.class, HttpClientAutoConfiguration.class, + RestClientAutoConfiguration.class, SpringBootAdminClientAutoConfiguration.class, + SpringBootAdminClientInetUtilsAutoConfiguration.class, + SpringBootAdminClientCloudFoundryAutoConfiguration.class); + if (type == WebApplicationType.SERVLET) { + return new WebApplicationContextRunner().withConfiguration(configurations) + .withConfiguration(AutoConfigurations.of(DispatcherServletAutoConfiguration.class)) + .withPropertyValues("spring.boot.admin.client.url=http://localhost:8081"); + } + return new ReactiveWebApplicationContextRunner().withConfiguration(configurations) + .withBean(WebFluxProperties.class) + .withPropertyValues("spring.boot.admin.client.url=http://localhost:8081"); + } + + private Class defaultFactoryType(WebApplicationType type) { + return (type == WebApplicationType.SERVLET) ? ServletApplicationFactory.class + : ReactiveApplicationFactory.class; + } + + private Class inetUtilsFactoryType(WebApplicationType type) { + return (type == WebApplicationType.SERVLET) ? InetUtilsServletApplicationFactory.class + : InetUtilsReactiveApplicationFactory.class; + } + +} diff --git a/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/registration/InetUtilsApplicationFactoryTest.java b/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/registration/InetUtilsApplicationFactoryTest.java new file mode 100644 index 00000000000..c32ce1e87c7 --- /dev/null +++ b/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/registration/InetUtilsApplicationFactoryTest.java @@ -0,0 +1,249 @@ +/* + * Copyright 2014-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 de.codecentric.boot.admin.client.registration; + +import java.net.InetAddress; +import java.net.UnknownHostException; +import java.util.Collections; +import java.util.stream.Stream; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.Arguments; +import org.junit.jupiter.params.provider.EnumSource; +import org.junit.jupiter.params.provider.MethodSource; +import org.mockito.MockedStatic; +import org.springframework.boot.WebApplicationType; +import org.springframework.boot.actuate.autoconfigure.endpoint.web.WebEndpointProperties; +import org.springframework.boot.actuate.autoconfigure.web.server.ManagementServerProperties; +import org.springframework.boot.actuate.endpoint.EndpointId; +import org.springframework.boot.actuate.endpoint.web.PathMappedEndpoints; +import org.springframework.boot.web.server.Ssl; +import org.springframework.boot.web.server.WebServer; +import org.springframework.boot.web.server.autoconfigure.ServerProperties; +import org.springframework.boot.web.server.context.WebServerApplicationContext; +import org.springframework.boot.web.server.context.WebServerInitializedEvent; +import org.springframework.boot.webflux.autoconfigure.WebFluxProperties; +import org.springframework.boot.webmvc.autoconfigure.DispatcherServletPath; +import org.springframework.cloud.commons.util.InetUtils; +import org.springframework.mock.web.MockServletContext; + +import de.codecentric.boot.admin.client.config.InstanceProperties; +import de.codecentric.boot.admin.client.config.ServiceHostType; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +class InetUtilsApplicationFactoryTest { + + private final InstanceProperties instance = new InstanceProperties(); + + private final ServerProperties server = new ServerProperties(); + + private final ManagementServerProperties management = new ManagementServerProperties(); + + private final PathMappedEndpoints pathMappedEndpoints = mock(PathMappedEndpoints.class); + + private final WebEndpointProperties webEndpoint = new WebEndpointProperties(); + + private final InetUtils inetUtils = mock(InetUtils.class); + + @BeforeEach + void setup() { + instance.setServiceHostType(ServiceHostType.IP); + management.setBasePath("/management"); + when(pathMappedEndpoints.getPath(EndpointId.of("health"))).thenReturn("/actuator/health"); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void uses_preferred_address(WebApplicationType type) throws UnknownHostException { + InetAddress address = InetAddress.getByAddress("preferred-host", new byte[] { (byte) 192, 0, 2, 10 }); + when(inetUtils.findFirstNonLoopbackAddress()).thenReturn(address); + DefaultApplicationFactory factory = createFactory(type); + publishPort(factory, "server", 8080); + publishPort(factory, "management", 9090); + + Application application = factory.createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("http://192.0.2.10:8080/app"); + assertThat(application.getManagementUrl()).isEqualTo("http://192.0.2.10:9090/management/actuator"); + assertThat(application.getHealthUrl()).isEqualTo("http://192.0.2.10:9090/management/actuator/health"); + } + + @ParameterizedTest + @MethodSource("hostTypes") + void preserves_host_type(WebApplicationType type, ServiceHostType hostType, String host) { + InetAddress address = mock(InetAddress.class); + when(address.getHostAddress()).thenReturn("192.0.2.10"); + when(address.getHostName()).thenReturn("preferred-host"); + when(address.getCanonicalHostName()).thenReturn("preferred-host.example"); + when(inetUtils.findFirstNonLoopbackAddress()).thenReturn(address); + instance.setServiceHostType(hostType); + DefaultApplicationFactory factory = createFactory(type); + publishPort(factory, "server", 8080); + + assertThat(factory.createApplication().getServiceUrl()).isEqualTo("http://" + host + ":8080/app"); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void falls_back_when_no_address_is_available(WebApplicationType type) throws UnknownHostException { + assertThat(createFactory(type).getLocalHost()).isEqualTo(InetAddress.getLocalHost()); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_host_resolution_failure(WebApplicationType type) { + DefaultApplicationFactory factory = createFactory(type); + UnknownHostException failure = new UnknownHostException("Local host unavailable"); + try (MockedStatic addresses = mockStatic(InetAddress.class)) { + addresses.when(InetAddress::getLocalHost).thenThrow(failure); + assertThatThrownBy(factory::getLocalHost).isInstanceOf(IllegalArgumentException.class) + .hasMessage(failure.getMessage()) + .hasCause(failure); + } + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_explicit_addresses(WebApplicationType type) throws UnknownHostException { + server.setAddress(InetAddress.getByName("127.0.0.1")); + management.setAddress(InetAddress.getByName("127.0.0.2")); + DefaultApplicationFactory factory = createFactory(type); + publishPort(factory, "server", 8080); + publishPort(factory, "management", 9090); + + Application application = factory.createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("http://127.0.0.1:8080/app"); + assertThat(application.getManagementUrl()).isEqualTo("http://127.0.0.2:9090/management/actuator"); + assertThat(application.getHealthUrl()).isEqualTo("http://127.0.0.2:9090/management/actuator/health"); + verifyNoInteractions(inetUtils); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_explicit_management_address(WebApplicationType type) throws UnknownHostException { + when(inetUtils.findFirstNonLoopbackAddress()) + .thenReturn(InetAddress.getByAddress(new byte[] { (byte) 192, 0, 2, 10 })); + management.setAddress(InetAddress.getByName("127.0.0.2")); + DefaultApplicationFactory factory = createFactory(type); + publishPort(factory, "server", 8080); + publishPort(factory, "management", 9090); + + Application application = factory.createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("http://192.0.2.10:8080/app"); + assertThat(application.getManagementUrl()).isEqualTo("http://127.0.0.2:9090/management/actuator"); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_explicit_urls(WebApplicationType type) { + instance.setServiceUrl("https://service.example"); + instance.setManagementUrl("https://management.example"); + instance.setHealthUrl("https://health.example"); + + Application application = createFactory(type).createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("https://service.example"); + assertThat(application.getManagementUrl()).isEqualTo("https://management.example"); + assertThat(application.getHealthUrl()).isEqualTo("https://health.example"); + verifyNoInteractions(inetUtils); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_base_urls(WebApplicationType type) { + instance.setServiceBaseUrl("https://service.example"); + instance.setManagementBaseUrl("https://management.example"); + + Application application = createFactory(type).createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("https://service.example/app"); + assertThat(application.getManagementUrl()).isEqualTo("https://management.example/actuator"); + assertThat(application.getHealthUrl()).isEqualTo("https://management.example/actuator/health"); + verifyNoInteractions(inetUtils); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_paths_on_shared_port(WebApplicationType type) throws UnknownHostException { + when(inetUtils.findFirstNonLoopbackAddress()) + .thenReturn(InetAddress.getByAddress(new byte[] { (byte) 192, 0, 2, 10 })); + DefaultApplicationFactory factory = createFactory(type); + publishPort(factory, "server", 8080); + String managementBaseUrl = "http://192.0.2.10:8080/app" + + ((type == WebApplicationType.SERVLET) ? "/dispatcher/management" : ""); + + Application application = factory.createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("http://192.0.2.10:8080/app"); + assertThat(application.getManagementUrl()).isEqualTo(managementBaseUrl + "/actuator"); + assertThat(application.getHealthUrl()).isEqualTo(managementBaseUrl + "/actuator/health"); + } + + @ParameterizedTest + @EnumSource(value = WebApplicationType.class, names = { "SERVLET", "REACTIVE" }) + void preserves_management_ssl(WebApplicationType type) throws UnknownHostException { + when(inetUtils.findFirstNonLoopbackAddress()) + .thenReturn(InetAddress.getByAddress(new byte[] { (byte) 192, 0, 2, 10 })); + management.setSsl(new Ssl()); + management.getSsl().setEnabled(true); + DefaultApplicationFactory factory = createFactory(type); + publishPort(factory, "server", 8080); + publishPort(factory, "management", 9090); + + Application application = factory.createApplication(); + assertThat(application.getServiceUrl()).isEqualTo("http://192.0.2.10:8080/app"); + assertThat(application.getManagementUrl()).isEqualTo("https://192.0.2.10:9090/management/actuator"); + assertThat(application.getHealthUrl()).isEqualTo("https://192.0.2.10:9090/management/actuator/health"); + } + + private DefaultApplicationFactory createFactory(WebApplicationType type) { + if (type == WebApplicationType.SERVLET) { + MockServletContext servletContext = new MockServletContext(); + servletContext.setContextPath("/app"); + DispatcherServletPath dispatcherServletPath = mock(DispatcherServletPath.class); + when(dispatcherServletPath.getPrefix()).thenReturn("/dispatcher"); + return new InetUtilsServletApplicationFactory(instance, management, server, servletContext, + pathMappedEndpoints, webEndpoint, Collections::emptyMap, dispatcherServletPath, inetUtils); + } + WebFluxProperties webFluxProperties = new WebFluxProperties(); + webFluxProperties.setBasePath("/app"); + return new InetUtilsReactiveApplicationFactory(instance, management, server, pathMappedEndpoints, webEndpoint, + Collections::emptyMap, webFluxProperties, inetUtils); + } + + private void publishPort(DefaultApplicationFactory factory, String namespace, int port) { + WebServerInitializedEvent event = mock(WebServerInitializedEvent.class); + WebServerApplicationContext context = mock(WebServerApplicationContext.class); + WebServer webServer = mock(WebServer.class); + when(context.getServerNamespace()).thenReturn(namespace); + when(webServer.getPort()).thenReturn(port); + when(event.getApplicationContext()).thenReturn(context); + when(event.getWebServer()).thenReturn(webServer); + factory.onWebServerInitialized(event); + } + + private static Stream hostTypes() { + return Stream.of(WebApplicationType.SERVLET, WebApplicationType.REACTIVE) + .flatMap((type) -> Stream.of(Arguments.of(type, ServiceHostType.IP, "192.0.2.10"), + Arguments.of(type, ServiceHostType.HOST_NAME, "preferred-host"), + Arguments.of(type, ServiceHostType.CANONICAL_HOST_NAME, "preferred-host.example"))); + } + +} diff --git a/spring-boot-admin-docs/src/site/docs/03-client/20-registration.md b/spring-boot-admin-docs/src/site/docs/03-client/20-registration.md index 9fbcebc08e2..fba4a8264b2 100644 --- a/spring-boot-admin-docs/src/site/docs/03-client/20-registration.md +++ b/spring-boot-admin-docs/src/site/docs/03-client/20-registration.md @@ -184,6 +184,33 @@ spring: - **`IP`**: Use the IP address - **`CANONICAL`**: Use the canonical hostname +### Spring Cloud InetUtils + +When a Spring Cloud `InetUtils` bean is available, the client uses it to select the local address for registration. +The servlet and reactive factories retain their usual context paths and management URL handling. + +For example, prefer a network and ignore interfaces through Spring Cloud configuration: + +```yaml +spring: + cloud: + inetutils: + preferred-networks: + - '192.168.' + ignored-interfaces: + - 'docker.*' + boot: + admin: + client: + instance: + service-host-type: IP +``` + +Explicit registration URLs, `server.address`, and `management.server.address` retain their usual precedence. +If `InetUtils` returns no address, the client falls back to `InetAddress.getLocalHost()`. +Applications without Spring Cloud continue to use the default factories. +Custom `ApplicationFactory` beans and the Cloud Foundry factory take precedence over the InetUtils factories. + ### Custom ApplicationFactory Create a custom factory for specialized registration logic: From b88359d1b2b25bc33bbf415132bb8c9fb35e5f99 Mon Sep 17 00:00:00 2001 From: myProjectsRavi <95069115+myProjectsRavi@users.noreply.github.com> Date: Sat, 3 Oct 2026 22:26:54 -0700 Subject: [PATCH 2/2] test: isolate default client registration tests Exclude InetUtils auto-configuration from tests that expect the default local host lookup. --- .../boot/admin/client/AbstractClientApplicationTest.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/AbstractClientApplicationTest.java b/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/AbstractClientApplicationTest.java index dcf04f819ac..828cbf2c8da 100644 --- a/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/AbstractClientApplicationTest.java +++ b/spring-boot-admin-client/src/test/java/de/codecentric/boot/admin/client/AbstractClientApplicationTest.java @@ -37,6 +37,7 @@ import org.springframework.context.ConfigurableApplicationContext; import org.springframework.context.event.EventListener; +import de.codecentric.boot.admin.client.config.SpringBootAdminClientInetUtilsAutoConfiguration; import de.codecentric.boot.admin.client.registration.ApplicationRegistrator; import static com.github.tomakehurst.wiremock.client.WireMock.created; @@ -144,7 +145,7 @@ private int getManagementPort() { } @SpringBootConfiguration - @EnableAutoConfiguration + @EnableAutoConfiguration(exclude = SpringBootAdminClientInetUtilsAutoConfiguration.class) public static class TestClientApplication { @Autowired