diff --git a/src/main/java/org/voltdb/meshmonitor/cli/BaseInetSocketAddressConverter.java b/src/main/java/org/voltdb/meshmonitor/cli/BaseInetSocketAddressConverter.java new file mode 100644 index 0000000..9df96c1 --- /dev/null +++ b/src/main/java/org/voltdb/meshmonitor/cli/BaseInetSocketAddressConverter.java @@ -0,0 +1,145 @@ +/* + * Copyright (C) 2024-2025 Volt Active Data Inc. + * + * Use of this source code is governed by an MIT + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.voltdb.meshmonitor.cli; + +import java.net.InetSocketAddress; + +import picocli.CommandLine; + +public abstract class BaseInetSocketAddressConverter implements CommandLine.ITypeConverter { + + protected abstract int getDefaultPort(); + protected abstract boolean requiresHostname(); + protected abstract boolean treatPlainValueAsPort(); + + @Override + public InetSocketAddress convert(String value) { + value = value.trim(); + + // start with null host and default port, to be replaced as we go + String host; + int port = getDefaultPort(); + + // IPv6 must start with brackets + if (value.startsWith("[")) { + + // and must have a close bracket + int closeBracket = value.indexOf("]"); + if (closeBracket == -1) { + throw new IllegalArgumentException("IPv6 address missing closing bracket"); + } + + // host is within the brackets + host = value.substring(1, closeBracket); + + String remainder = value.substring(closeBracket + 1); + + if (remainder.isEmpty() || remainder.equals(":")) { + // no port provided, keep the default port + } else if (remainder.startsWith(":")) { + port = Integer.parseInt(remainder.substring(1)); + } else { + throw new IllegalArgumentException("Invalid format after IPv6 address"); + } + + validateHost(host); + validatePort(port); + if (host.isEmpty() && treatPlainValueAsPort()) { + return new InetSocketAddress(port); + } else { + return new InetSocketAddress(host, port); + } + + } else { + + int lastColon = value.lastIndexOf(":"); + + if (lastColon == -1) { + // there is no colon + return handlePlainValue(value); + } + + if (value.indexOf(":") != lastColon) { + // There is more than one colon + + // Either it's invalid or it's an IPv6 address without brackets, which we require + // otherwise there is ambiguity between the port vs. the last group of the address + throw new IllegalArgumentException("Too many colons or IPv6 address missing brackets"); + } + + if (value.equals(":")) { + // there is only just a colon, treat this the same as "" + return handlePlainValue(""); + } + + if (lastColon == 0) { + // no hostname given, but the colon indicates value should be the port + if (requiresHostname()) { + throw new IllegalArgumentException("Hostname is required. Please provide a valid FQDN or IP address."); + } else { + // skip over the colon and handle Plain value as a port + port = Integer.parseInt(value.substring(1)); + validatePort(port); + return new InetSocketAddress(port); + } + } + + // there is one and only one colon + host = value.substring(0, lastColon); + String portString = value.substring(lastColon + 1); + if (!portString.isEmpty()) { + port = Integer.parseInt(portString); + } + validateHost(host); + validatePort(port); + return new InetSocketAddress(host, port); + } + } + + private InetSocketAddress handlePlainValue(String value) { + if (value.isEmpty()) { + if (requiresHostname()) { + throw new IllegalArgumentException("Hostname is required. Please provide a valid FQDN or IP address."); + } else { + // use default port + wildcard + return new InetSocketAddress(getDefaultPort()); + } + } else if (treatPlainValueAsPort() && isValidPort(value)) { + int port = Integer.parseInt(value); + return new InetSocketAddress(port); // wildcard + } else { + validateHost(value); + return new InetSocketAddress(value, getDefaultPort()); + } + } + + private void validateHost(String host) { + if (requiresHostname() && (host == null || host.isEmpty())) { + throw new IllegalArgumentException("Hostname is required. Please provide a valid FQDN or IP address."); + } + } + + private void validatePort(int port) { + if (!portInValidRange(port)) { + throw new IllegalArgumentException("Port must be in range 1 - 65535"); + } + } + + private boolean isValidPort(String input) { + try { + int port = Integer.parseInt(input); + return portInValidRange(port); + } catch (NumberFormatException e) { + return false; + } + } + + private boolean portInValidRange(int port) { + return port >=1 && port <= 65535; + } +} diff --git a/src/main/java/org/voltdb/meshmonitor/cli/BindInetSocketAddressConverter.java b/src/main/java/org/voltdb/meshmonitor/cli/BindInetSocketAddressConverter.java new file mode 100644 index 0000000..8ebf366 --- /dev/null +++ b/src/main/java/org/voltdb/meshmonitor/cli/BindInetSocketAddressConverter.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-2025 Volt Active Data Inc. + * + * Use of this source code is governed by an MIT + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.voltdb.meshmonitor.cli; + +public class BindInetSocketAddressConverter extends BaseInetSocketAddressConverter { + + @Override + protected int getDefaultPort() { + return 12222; + } + + @Override + protected boolean requiresHostname() { + return true; + } + + @Override + protected boolean treatPlainValueAsPort() { + return false; + } +} diff --git a/src/main/java/org/voltdb/meshmonitor/cli/InetSocketAddressConverter.java b/src/main/java/org/voltdb/meshmonitor/cli/InetSocketAddressConverter.java deleted file mode 100644 index d5d6885..0000000 --- a/src/main/java/org/voltdb/meshmonitor/cli/InetSocketAddressConverter.java +++ /dev/null @@ -1,30 +0,0 @@ -/* - * Copyright (C) 2024 Volt Active Data Inc. - * - * Use of this source code is governed by an MIT - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -package org.voltdb.meshmonitor.cli; - -import java.net.InetSocketAddress; - -import picocli.CommandLine; - -public class InetSocketAddressConverter implements CommandLine.ITypeConverter { - - public static final int DEFAULT_PORT = 12222; - - @Override - public InetSocketAddress convert(String value) { - int port = DEFAULT_PORT; - - int pos = value.lastIndexOf(':'); - if (pos >= 0) { - port = Integer.parseInt(value.substring(pos + 1)); - return new InetSocketAddress(value.substring(0, pos), port); - } - - return new InetSocketAddress(value, port); - } -} diff --git a/src/main/java/org/voltdb/meshmonitor/cli/MeshMonitorCommand.java b/src/main/java/org/voltdb/meshmonitor/cli/MeshMonitorCommand.java index 8827e9a..34e647d 100644 --- a/src/main/java/org/voltdb/meshmonitor/cli/MeshMonitorCommand.java +++ b/src/main/java/org/voltdb/meshmonitor/cli/MeshMonitorCommand.java @@ -71,9 +71,10 @@ public class MeshMonitorCommand implements Callable { @CommandLine.Option( names = {"-b", "--bind"}, - description = "Bind address in format ipv4[:port]", + required = true, + description = "Bind address in format host[:port]", defaultValue = "127.0.0.1:12222", - converter = InetSocketAddressConverter.class) + converter = BindInetSocketAddressConverter.class) private InetSocketAddress bindAddress; @CommandLine.Option( @@ -85,7 +86,7 @@ public class MeshMonitorCommand implements Callable { names = {"-m", "--metrics-bind"}, description = "Bind address for metrics server in format [host][:port]. Default is 12223 for all interfaces", defaultValue = "12223", - converter = InetSocketAddressConverter.class) + converter = MetricsInetSocketAddressConverter.class) private InetSocketAddress metricsBindAddress; @CommandLine.Option( @@ -103,7 +104,7 @@ public class MeshMonitorCommand implements Callable { @CommandLine.Parameters( arity = "0..*", description = "Whitespace separated list of servers to maintain permanent connection to, e.g. 192.168.0.1 192.168.0.2 1926.168.0.12", - converter = InetSocketAddressConverter.class) + converter = BindInetSocketAddressConverter.class) private List servers = new ArrayList<>(); @CommandLine.Spec diff --git a/src/main/java/org/voltdb/meshmonitor/cli/MetricsInetSocketAddressConverter.java b/src/main/java/org/voltdb/meshmonitor/cli/MetricsInetSocketAddressConverter.java new file mode 100644 index 0000000..0e2d8e1 --- /dev/null +++ b/src/main/java/org/voltdb/meshmonitor/cli/MetricsInetSocketAddressConverter.java @@ -0,0 +1,26 @@ +/* + * Copyright (C) 2024-2025 Volt Active Data Inc. + * + * Use of this source code is governed by an MIT + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.voltdb.meshmonitor.cli; + +public class MetricsInetSocketAddressConverter extends BaseInetSocketAddressConverter { + + @Override + protected int getDefaultPort() { + return 12223; + } + + @Override + protected boolean requiresHostname() { + return false; + } + + @Override + protected boolean treatPlainValueAsPort() { + return true; + } +} diff --git a/src/test/java/org/voltdb/meshmonitor/cli/InetSocketAddressConverterTest.java b/src/test/java/org/voltdb/meshmonitor/cli/InetSocketAddressConverterTest.java deleted file mode 100644 index b2dd14b..0000000 --- a/src/test/java/org/voltdb/meshmonitor/cli/InetSocketAddressConverterTest.java +++ /dev/null @@ -1,96 +0,0 @@ -/* - * Copyright (C) 2024 Volt Active Data Inc. - * - * Use of this source code is governed by an MIT - * license that can be found in the LICENSE file or at - * https://opensource.org/licenses/MIT. - */ -package org.voltdb.meshmonitor.cli; - -import org.junit.jupiter.api.Test; - -import java.net.InetSocketAddress; -import java.net.UnknownHostException; - -import static org.assertj.core.api.Assertions.assertThat; -import static org.assertj.core.api.Assertions.assertThatThrownBy; - -public class InetSocketAddressConverterTest { - - private final InetSocketAddressConverter converter = new InetSocketAddressConverter(); - - @Test - public void shouldConvertWithPort() { - // Given - String input = "localhost:8080"; - - // When - InetSocketAddress result = converter.convert(input); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getHostName()).isEqualTo("localhost"); - assertThat(result.getPort()).isEqualTo(8080); - } - - @Test - public void shouldConvertWithoutPort() { - // Given - String input = "localhost"; - - // When - InetSocketAddress result = converter.convert(input); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getHostName()).isEqualTo("localhost"); - assertThat(result.getPort()).isEqualTo(InetSocketAddressConverter.DEFAULT_PORT); - } - - @Test - public void shouldThrowExceptionWithInvalidPort() { - // Given - String input = "localhost:abc"; - - // When & Then - assertThatThrownBy(() -> converter.convert(input)) - .isInstanceOf(NumberFormatException.class); - } - - @Test - public void emptyStringShouldDefaultToLocalhost() { - // Given - String input = ""; - - // When - InetSocketAddress result = converter.convert(input); - - assertThat(result).isNotNull(); - assertThat(result.getHostName()).isEqualTo("localhost"); - assertThat(result.getPort()).isEqualTo(InetSocketAddressConverter.DEFAULT_PORT); - } - - @Test - public void shouldThrowExceptionOnInputWithOnlyColon() { - // Given - String input = ":"; - - // When & Then - assertThatThrownBy(() -> converter.convert(input)) - .isInstanceOf(IllegalArgumentException.class); - } - - @Test - public void shouldDefaultToLocalhostIfONlyColonAndPortIsSpecified() throws UnknownHostException { - // Given - String input = ":8080"; - - // When - InetSocketAddress result = converter.convert(input); - - // Then - assertThat(result).isNotNull(); - assertThat(result.getHostName()).isEqualTo("localhost"); - assertThat(result.getPort()).isEqualTo(8080); - } -} diff --git a/src/test/java/org/voltdb/meshmonitor/cli/InetSocketAddressConvertersTest.java b/src/test/java/org/voltdb/meshmonitor/cli/InetSocketAddressConvertersTest.java new file mode 100644 index 0000000..58c0afd --- /dev/null +++ b/src/test/java/org/voltdb/meshmonitor/cli/InetSocketAddressConvertersTest.java @@ -0,0 +1,276 @@ +/* + * Copyright (C) 2024-2025 Volt Active Data Inc. + * + * Use of this source code is governed by an MIT + * license that can be found in the LICENSE file or at + * https://opensource.org/licenses/MIT. + */ +package org.voltdb.meshmonitor.cli; + +import org.junit.jupiter.api.Test; + +import java.net.InetSocketAddress; +import java.net.UnknownHostException; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +public class InetSocketAddressConvertersTest { + + // This test covers both custom converter classes: + private final BindInetSocketAddressConverter bconverter = new BindInetSocketAddressConverter(); + // Metrics Converter: + // - can be port only (wildcard host) + // - default port is 12223 + + private final MetricsInetSocketAddressConverter mconverter = new MetricsInetSocketAddressConverter(); + // Bind Converter: + // - must have a hostname (it is advertised) + // - defaults to port 12222 + + private final BaseInetSocketAddressConverter[] converters = {bconverter, mconverter}; + + private void testIllegalArgument(BaseInetSocketAddressConverter converter, String input) { + assertThatThrownBy(() -> converter.convert(input)) + .isInstanceOf(IllegalArgumentException.class); + } + + // --------- TESTS THAT ARE IDENTICAL FOR BOTH CONVERTERS ---------- + @Test + public void ipv6MissingOneBracket() { + String input = "[1:2:3:4"; + + for (BaseInetSocketAddressConverter c : converters) { + testIllegalArgument(c, input); + } + + input = "1:2:3:4]"; + + for (BaseInetSocketAddressConverter c : converters) { + testIllegalArgument(c, input); + } + } + + @Test + public void ipv6NoBrackets() { + String input = "1:2:3:4"; + + for (BaseInetSocketAddressConverter c : converters) { + testIllegalArgument(c, input); + } + + input = "1:2:3:4:1000"; // no brackets with port + + for (BaseInetSocketAddressConverter c : converters) { + testIllegalArgument(c, input); + } + } + + @Test + public void ipv6NoColon() { + String input = "[1:2:3:4]8080"; + + for (BaseInetSocketAddressConverter c : converters) { + testIllegalArgument(c, input); + } + } + + @Test + public void hostnameWithPort() { + + String input = "localhost:8080"; + + for (BaseInetSocketAddressConverter c : converters) { + InetSocketAddress result = c.convert(input); + + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("localhost"); + assertThat(result.getPort()).isEqualTo(8080); + } + + input = "10.0.0.1:8080"; + + for (BaseInetSocketAddressConverter c : converters) { + InetSocketAddress result = c.convert(input); + + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("10.0.0.1"); + assertThat(result.getPort()).isEqualTo(8080); + } + + input = "[1:2:3:4]:8080"; + + for (BaseInetSocketAddressConverter c : converters) { + InetSocketAddress result = c.convert(input); + + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("1:2:3:4"); + assertThat(result.getPort()).isEqualTo(8080); + } + } + + @Test + public void hostnameOnly() { + + String input = "localhost"; + + // Bind accepts localhost, adds default port + InetSocketAddress result = bconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("localhost"); + assertThat(result.getPort()).isEqualTo(bconverter.getDefaultPort()); + + // Metrics accepts localhost, adds different default port + result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("localhost"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + + input = "10.0.0.1"; + + // Bind accepts IPv4 host, adds default port + result = bconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo(input); + assertThat(result.getPort()).isEqualTo(bconverter.getDefaultPort()); + + // Metrics accepts IPv4 host, adds different default port + result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo(input); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + + input = "[1:2:3:4]"; + + // Bind accepts IPv4 host, adds default port + result = bconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("1:2:3:4"); + assertThat(result.getPort()).isEqualTo(bconverter.getDefaultPort()); + + // Metrics accepts IPv4 host, adds different default port + result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("1:2:3:4"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + } + + @Test + public void hostnameColonNoPort() { // should ignore the extra colon + + String input = "localhost:"; + + // Bind accepts localhost, adds default port + InetSocketAddress result = bconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("localhost"); + assertThat(result.getPort()).isEqualTo(bconverter.getDefaultPort()); + + // Metrics accepts localhost, adds different default port + result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("localhost"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + + input = "10.0.0.1:"; + + // Bind accepts IPv4 host, adds default port + result = bconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("10.0.0.1"); + assertThat(result.getPort()).isEqualTo(bconverter.getDefaultPort()); + + // Metrics accepts IPv4 host, adds different default port + result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("10.0.0.1"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + + input = "[1:2:3:4]:"; + + // Bind accepts IPv4 host, adds default port + result = bconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("1:2:3:4"); + assertThat(result.getPort()).isEqualTo(bconverter.getDefaultPort()); + + // Metrics accepts IPv4 host, adds different default port + result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("1:2:3:4"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + } + + @Test + public void invalidPort() { + + String input = "localhost:abc"; + + for (BaseInetSocketAddressConverter c : converters) { + assertThatThrownBy(() -> c.convert(input)) + .isInstanceOf(NumberFormatException.class); + } + } + + // --------- TESTS THAT ARE DIFFERENT FOR EACH CONVERTER ---------- + + @Test + public void colonPort() throws UnknownHostException { + + String input = ":8080"; + + // Bind converter requires hostname + testIllegalArgument(bconverter, input); + + // Metrics converter accepts port only (uses wildcard) + InetSocketAddress result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("0.0.0.0"); + assertThat(result.getPort()).isEqualTo(8080); + } + + @Test + public void emptyBracketsColonPort() throws UnknownHostException { + + String input = "[]:8080"; + + // Bind converter requires hostname + testIllegalArgument(bconverter, input); + + // Metrics converter accepts port only (uses wildcard) + InetSocketAddress result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("0.0.0.0"); + assertThat(result.getPort()).isEqualTo(8080); + } + + @Test + public void colonOnly() { + + String input = ":"; + + // Metrics should use default port and wildcard + InetSocketAddress result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("0.0.0.0"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + + // Bind should throw exception - hostname is required + testIllegalArgument(bconverter, input); + } + + @Test + public void emptyString() { + + String input = ""; + + // Metrics should use default port and wildcard + InetSocketAddress result = mconverter.convert(input); + assertThat(result).isNotNull(); + assertThat(result.getHostName()).isEqualTo("0.0.0.0"); + assertThat(result.getPort()).isEqualTo(mconverter.getDefaultPort()); + + // Bind should throw exception - hostname is required + testIllegalArgument(bconverter, input); + } +}