diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/internal/GrpcUtils.java b/spring-grpc-core/src/main/java/org/springframework/grpc/internal/GrpcUtils.java index 9f5b164b..e15176d3 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/internal/GrpcUtils.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/internal/GrpcUtils.java @@ -16,6 +16,12 @@ package org.springframework.grpc.internal; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.Objects; + +import org.springframework.util.StringUtils; + /** * Provides convenience methods for various gRPC functions. * @@ -36,9 +42,21 @@ private GrpcUtils() { */ public static int getPort(String address) { String value = address; - if (value.contains(":")) { + long numberOfColons = countColons(address); + if (numberOfColons == 1) { value = value.substring(value.lastIndexOf(":") + 1); } + else if (numberOfColons > 1) { + if (address.startsWith("[")) { + var index = address.lastIndexOf("]:"); + if (index >= 0) { + value = address.substring(index + 2); + } + else { + return DEFAULT_PORT; + } + } + } if (value.contains("/")) { value = value.substring(0, value.indexOf("/")); } @@ -51,4 +69,68 @@ public static int getPort(String address) { return DEFAULT_PORT; } + public static long countColons(String address) { + return address.chars().filter(ch -> ch == ':').count(); + } + + /** + * Gets the hostname from a given address. + * @param address a hostname/IPv4/IPv6/empty/* optionally with a port specification + * @return the hostname or an empty string + * @see https://en.wikipedia.org/wiki/IPv6#Address_representation + */ + public static String getHostName(String address) { + String trimmedAddress = address.trim(); + long numberOfColons = countColons(trimmedAddress); + + if (numberOfColons == 0) { + return trimmedAddress; + } + + if (numberOfColons == 1) { // An IPv6 address mush have at least 2 colons, so is + // {IPv4 or hostname}:{port} + return trimmedAddress.split(":")[0].trim(); + } + + if (numberOfColons > 8 || numberOfColons == 8 && !trimmedAddress.startsWith("[")) { + // On an IPv6 address a maximum of 7 colons are allowed + 1 for the port + // IPv6 addresses with port should have the format [{address}]:port + throw new IllegalArgumentException("Cannot parse address: " + trimmedAddress); + } + + if (trimmedAddress.startsWith("[")) { + var index = trimmedAddress.lastIndexOf("]"); + if (index < 0) { + throw new IllegalArgumentException("Cannot parse address: " + trimmedAddress); + } + return trimmedAddress.substring(1, index); + } + + return trimmedAddress; // IPv6 Address with no port specified + } + + /** + * Gets a SocketAddress for the given address. + * + * If the address part is empty, * or :: a SocketAddress with wildcard address will be + * returned. If the port part is empty or missing, a SocketAddress for the gRPC + * default port (9090) will be returned. + * @param address a hostname/IPv4/IPv6/empty/* optionally with a port specification + * @return a SocketAddress representation for the given address + */ + public static SocketAddress getSocketAddress(String address) { + if (address.startsWith("unix:")) { + throw new UnsupportedOperationException("Unix socket addresses not supported"); + } + + var host = getHostName(address); + if (StringUtils.hasText(host) && !Objects.equals(host, "*") && !Objects.equals(host, "::")) { + return new InetSocketAddress(host, getPort(address)); + } + else { + return new InetSocketAddress(getPort(address)); + } + } + } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/NettyGrpcServerFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/NettyGrpcServerFactory.java index 7562f60e..c68be7dd 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/NettyGrpcServerFactory.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/NettyGrpcServerFactory.java @@ -23,6 +23,8 @@ import org.jspecify.annotations.Nullable; +import org.springframework.grpc.internal.GrpcUtils; + import io.grpc.TlsServerCredentials.ClientAuth; import io.grpc.netty.NettyServerBuilder; import io.netty.channel.MultiThreadIoEventLoopGroup; @@ -56,7 +58,7 @@ protected NettyServerBuilder newServerBuilder() { .bossEventLoopGroup(new MultiThreadIoEventLoopGroup(1, EpollIoHandler.newFactory())) .workerEventLoopGroup(new MultiThreadIoEventLoopGroup(EpollIoHandler.newFactory())); } - return super.newServerBuilder(); + return NettyServerBuilder.forAddress(GrpcUtils.getSocketAddress(address()), credentials()); } } diff --git a/spring-grpc-core/src/main/java/org/springframework/grpc/server/ShadedNettyGrpcServerFactory.java b/spring-grpc-core/src/main/java/org/springframework/grpc/server/ShadedNettyGrpcServerFactory.java index 5a493470..98de3aae 100644 --- a/spring-grpc-core/src/main/java/org/springframework/grpc/server/ShadedNettyGrpcServerFactory.java +++ b/spring-grpc-core/src/main/java/org/springframework/grpc/server/ShadedNettyGrpcServerFactory.java @@ -23,6 +23,8 @@ import org.jspecify.annotations.Nullable; +import org.springframework.grpc.internal.GrpcUtils; + import io.grpc.TlsServerCredentials.ClientAuth; import io.grpc.netty.shaded.io.grpc.netty.NettyServerBuilder; import io.grpc.netty.shaded.io.netty.channel.epoll.EpollEventLoopGroup; @@ -55,7 +57,7 @@ protected NettyServerBuilder newServerBuilder() { .bossEventLoopGroup(new EpollEventLoopGroup(1)) .workerEventLoopGroup(new EpollEventLoopGroup()); } - return super.newServerBuilder(); + return NettyServerBuilder.forAddress(GrpcUtils.getSocketAddress(address()), credentials()); } } diff --git a/spring-grpc-core/src/test/java/org/springframework/grpc/internal/GrpcUtilsTests.java b/spring-grpc-core/src/test/java/org/springframework/grpc/internal/GrpcUtilsTests.java index 43889224..a9e78d23 100644 --- a/spring-grpc-core/src/test/java/org/springframework/grpc/internal/GrpcUtilsTests.java +++ b/spring-grpc-core/src/test/java/org/springframework/grpc/internal/GrpcUtilsTests.java @@ -17,8 +17,15 @@ package org.springframework.grpc.internal; import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatExceptionOfType; +import java.net.InetSocketAddress; +import java.net.SocketAddress; +import java.util.List; + +import org.junit.jupiter.api.DynamicTest; import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.TestFactory; class GrpcUtilsTests { @@ -56,4 +63,48 @@ void testGetInvalidAddress() { assertThat(GrpcUtils.getPort(address)).isEqualTo(9090); // -1? } + @TestFactory + List ipAddress() { + return List.of(testIpAddress(":9999", new InetSocketAddress(9999)), + testIpAddress("localhost:9999", new InetSocketAddress("localhost", 9999)), + testIpAddress("localhost", new InetSocketAddress("localhost", 9090)), + testIpAddress("127.0.0.1", new InetSocketAddress("127.0.0.1", 9090)), + testIpAddress("127.0.0.1:8888", new InetSocketAddress("127.0.0.1", 8888)), + testIpAddress("*", new InetSocketAddress(9090)), testIpAddress("*:8888", new InetSocketAddress(8888)), + testIpAddress("", new InetSocketAddress(9090)), + // IPv6 cases. See + // https://en.wikipedia.org/wiki/IPv6#Address_representation + testIpAddress("[::]:8888", new InetSocketAddress(8888)), + testIpAddress("::", new InetSocketAddress(9090)), testIpAddress("[::]", new InetSocketAddress(9090)), + testIpAddress("::1", new InetSocketAddress("::1", 9090)), + testIpAddress("[::1]", new InetSocketAddress("::1", 9090)), + testIpAddress("[::1]:9999", new InetSocketAddress("::1", 9999)), + testIpAddress("2001:db8::ff00:42:8329", new InetSocketAddress("2001:db8::ff00:42:8329", 9090)), + testIpAddress("[2001:db8::ff00:42:8329]", new InetSocketAddress("2001:db8::ff00:42:8329", 9090)), + testIpAddress("[2001:db8::ff00:42:8329]:9999", new InetSocketAddress("2001:db8::ff00:42:8329", 9999)), + testIpAddress("::ffff:192.0.2.128", new InetSocketAddress("::ffff:192.0.2.128", 9090)), + testIpAddress("[::ffff:192.0.2.128]", new InetSocketAddress("::ffff:192.0.2.128", 9090)), + testIpAddress("[::ffff:192.0.2.128]:9999", new InetSocketAddress("::ffff:192.0.2.128", 9999))); + } + + private DynamicTest testIpAddress(String address, SocketAddress expected) { + return DynamicTest.dynamicTest("Socket address: " + address, () -> { + assertThat(GrpcUtils.getSocketAddress(address)).isEqualTo(expected); + }); + } + + @TestFactory + List unsupportedAddress() { + return List.of(testThrows("unix:dummy", UnsupportedOperationException.class), + testThrows("0:1:2:3:4:5:6:7:8:9", IllegalArgumentException.class), + testThrows("[0:1:2:3:4:5:6:7:8:9]", IllegalArgumentException.class), + testThrows("[0:1:2:3:4:5:6:7:8]:9", IllegalArgumentException.class), + testThrows("[0:1:2:3:4:5:6:7]:8:9", IllegalArgumentException.class)); + } + + private DynamicTest testThrows(String address, Class expectedException) { + return DynamicTest.dynamicTest("Socket address: " + address, () -> assertThatExceptionOfType(expectedException) + .isThrownBy(() -> GrpcUtils.getSocketAddress(address))); + } + } diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java b/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java index a6a752f6..6bbfeef1 100644 --- a/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java +++ b/spring-grpc-server-spring-boot-autoconfigure/src/main/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerProperties.java @@ -99,7 +99,15 @@ public void setAddress(@Nullable String address) { * @return the address to bind to */ public String determineAddress() { - return (this.address != null) ? this.address : this.host + ":" + this.port; + return (this.address != null) ? this.address : getAddressFromHostAndPort(); + } + + private String getAddressFromHostAndPort() { + return isIpV6() ? "[%s]:%d".formatted(this.host, this.port) : this.host + ":" + this.port; + } + + private boolean isIpV6() { + return GrpcUtils.countColons(this.host) >= 2; } public String getHost() { diff --git a/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java b/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java index 1676ec71..7c32fc01 100644 --- a/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java +++ b/spring-grpc-server-spring-boot-autoconfigure/src/test/java/org/springframework/boot/grpc/server/autoconfigure/GrpcServerPropertiesTests.java @@ -24,6 +24,8 @@ import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.springframework.boot.context.properties.bind.Binder; import org.springframework.boot.context.properties.source.MapConfigurationPropertySource; @@ -187,6 +189,28 @@ void addressTakesPrecedenceOverHostAndPort() { assertThat(properties.getAddress()).isEqualTo("my-server-ip:3130"); } + @ParameterizedTest + @ValueSource(strings = { "dummy.springframework.org", "127.0.0.1", "0.0.0.0", "192.168.1.2" }) + void hostnameOrIpv4HostAndPort(String hostName) { + Map map = new HashMap<>(); + map.put("spring.grpc.server.host", hostName); + map.put("spring.grpc.server.port", "1234"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getAddress()).isNullOrEmpty(); + assertThat(properties.determineAddress()).isEqualTo(hostName + ":1234"); + } + + @ParameterizedTest + @ValueSource(strings = { "::", "1:2:3:4:5:6:7:8", "::1" }) + void ipv6HostAndPort(String ipv6Address) { + Map map = new HashMap<>(); + map.put("spring.grpc.server.host", ipv6Address); + map.put("spring.grpc.server.port", "1234"); + GrpcServerProperties properties = bindProperties(map); + assertThat(properties.getAddress()).isNullOrEmpty(); + assertThat(properties.determineAddress()).isEqualTo("[" + ipv6Address + "]:1234"); + } + } }