diff --git a/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java b/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java index 9fa3bb257..8d76375d0 100644 --- a/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java +++ b/src/main/java/com/hubspot/jinjava/lib/filter/IpAddrFilter.java @@ -2,6 +2,8 @@ import com.google.common.base.Joiner; import com.google.common.base.Splitter; +import com.google.common.net.InetAddresses; +import com.googlecode.ipv6.IPv6Address; import com.googlecode.ipv6.IPv6Network; import com.hubspot.jinjava.doc.annotations.JinjavaDoc; import com.hubspot.jinjava.doc.annotations.JinjavaParam; @@ -11,6 +13,7 @@ import com.hubspot.jinjava.interpret.JinjavaInterpreter; import com.hubspot.jinjava.util.ForLoop; import com.hubspot.jinjava.util.ObjectIterator; +import java.math.BigInteger; import java.net.InetAddress; import java.net.UnknownHostException; import java.util.ArrayList; @@ -34,7 +37,10 @@ value = "function", type = "string", defaultValue = "prefix", - desc = "Name of function. Supported functions: 'prefix', 'netmask', 'network', 'address', 'broadcast'" + desc = "Name of function. Supported functions: 'prefix', 'netmask', 'network', 'address', 'broadcast', or 'wildcard'. " + + "A numeric index selects an address from the network and preserves its prefix; negative indexes count from the end. " + + "Out-of-range indexes return false, except that individual hosts are returned unchanged for any index. " + + "The 'wildcard' query returns the inverse netmask." ), }, snippets = { @@ -50,6 +56,22 @@ code = "{{ ['192.108.0.1', null, True, 13, '2000::'] | ipaddr }}", output = "['192.108.0.1', '2000::']" ), + @JinjavaSnippet( + desc = "Select the network address and first host address, retaining the CIDR prefix", + code = "{{ '192.168.10.0/24' | ipaddr(0) }}\n" + + "{{ '192.168.10.0/24' | ipaddr(1) }}", + output = "192.168.10.0/24\n192.168.10.1/24" + ), + @JinjavaSnippet( + desc = "A negative index selects an address from the end of the subnet", + code = "{{ '192.168.10.0/24' | ipaddr(-1) }}", + output = "192.168.10.255/24" + ), + @JinjavaSnippet( + desc = "Return the wildcard mask for an IPv4 subnet", + code = "{{ '192.168.10.0/24' | ipaddr('wildcard') }}", + output = "0.0.0.255" + ), } ) public class IpAddrFilter implements Filter { @@ -73,6 +95,7 @@ public class IpAddrFilter implements Filter { private static final String GATEWAY_STRING = "gateway"; private static final String PUBLIC_STRING = "public"; private static final String PRIVATE_STRING = "private"; + private static final String WILDCARD_STRING = "wildcard"; private static final String AVAILABLE_FUNCTIONS = Joiner .on(", ") .join( @@ -83,7 +106,9 @@ public class IpAddrFilter implements Filter { NETWORK_STRING, GATEWAY_STRING, PUBLIC_STRING, - PRIVATE_STRING + PRIVATE_STRING, + WILDCARD_STRING, + "numeric index" ); @Override @@ -214,8 +239,13 @@ private Object getValue( return ipAddress; } + boolean isv4 = IP4_PATTERN.matcher(ipAddress).matches(); + boolean numericIndex = parameter.matches("[+-]?[0-9]+"); if (parts.size() != 2) { - return null; + if (numericIndex) { + return ipAddress; + } + return parameter.equals(WILDCARD_STRING) ? (isv4 ? "0.0.0.0" : "::") : null; } String prefixString = parts.get(1); @@ -232,8 +262,22 @@ private Object getValue( ); } - boolean isv4 = IP4_PATTERN.matcher(ipAddress).matches(); + if (numericIndex) { + return getIndexedAddress(interpreter, fullAddress, prefix, isv4, parameter); + } switch (parameter) { + case WILDCARD_STRING: + if (isv4) { + SubnetUtils.SubnetInfo subnet = getSubnetUtils(interpreter, fullAddress) + .getInfo(); + return InetAddresses + .fromInteger(~subnet.asInteger(subnet.getNetmask())) + .getHostAddress(); + } + getIpv6Network(interpreter, fullAddress); // Validate the CIDR before shifting. + return IPv6Address + .fromBigInteger(BigInteger.ONE.shiftLeft(128 - prefix).subtract(BigInteger.ONE)) + .toString(); case PREFIX_STRING: return prefix; case NETMASK_STRING: @@ -268,6 +312,44 @@ private Object getValue( } } + /** Select an address relative to the network without enumerating the subnet. */ + private Object getIndexedAddress( + JinjavaInterpreter interpreter, + String address, + int prefix, + boolean isv4, + String parameter + ) { + BigInteger first; + if (isv4) { + SubnetUtils.SubnetInfo subnet = getSubnetUtils(interpreter, address).getInfo(); + // Convert the signed IPv4 integer to an unsigned value before doing arithmetic. + first = + BigInteger.valueOf(subnet.asInteger(subnet.getNetworkAddress()) & 0xffffffffL); + } else { + first = getIpv6Network(interpreter, address).getFirst().toBigInteger(); + } + BigInteger size = BigInteger.ONE.shiftLeft((isv4 ? 32 : 128) - prefix); + // Match Ansible: a single-address network returns its address for any index. + BigInteger index = size.equals(BigInteger.ONE) + ? BigInteger.ZERO + : new BigInteger(parameter); + if (index.signum() < 0) { + index = index.add(size); + } + if (index.signum() < 0 || index.compareTo(size) >= 0) { + return false; + } + BigInteger selected = first.add(index); + return ( + (isv4 + ? InetAddresses.fromInteger(selected.intValue()).getHostAddress() + : IPv6Address.fromBigInteger(selected).toString()) + + "/" + + prefix + ); + } + private SubnetUtils getSubnetUtils(JinjavaInterpreter interpreter, String address) { try { return new SubnetUtils(address); diff --git a/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java b/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java index 639e113af..b748c381a 100644 --- a/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java +++ b/src/test/java/com/hubspot/jinjava/lib/filter/IpAddrFilterTest.java @@ -4,6 +4,7 @@ import static org.assertj.core.api.AssertionsForClassTypes.assertThatThrownBy; import com.hubspot.jinjava.BaseInterpretingTest; +import com.hubspot.jinjava.interpret.InvalidArgumentException; import com.hubspot.jinjava.objects.SafeString; import java.util.Arrays; import java.util.HashMap; @@ -457,4 +458,101 @@ public void itFiltersIpAddressesPrivate() { assertThat(ipAddrFilter.filter(inputAddresses, interpreter, "private")) .isEqualTo(expectedAddresses); } + + @Test + public void itSelectsIpv4AddressesByIndex() { + assertThat(ipAddrFilter.filter("192.168.10.42/24", interpreter, "0")) + .isEqualTo("192.168.10.0/24"); + assertThat(ipAddrFilter.filter("192.168.10.0/24", interpreter, "1")) + .isEqualTo("192.168.10.1/24"); + assertThat(ipAddrFilter.filter("192.168.10.0/24", interpreter, "-1")) + .isEqualTo("192.168.10.255/24"); + assertThat(ipAddrFilter.filter("0.0.0.0/0", interpreter, "4294967295")) + .isEqualTo("255.255.255.255/0"); + assertThat(ipv4Filter.filter("255.255.255.254/31", interpreter, "1")) + .isEqualTo("255.255.255.255/31"); + assertThat(ipAddrFilter.filter("192.168.10.1/32", interpreter, "0")) + .isEqualTo("192.168.10.1/32"); + assertThat(ipAddrFilter.filter("192.168.10.1", interpreter, "1")) + .isEqualTo("192.168.10.1"); + } + + @Test + public void itSelectsIpv6AddressesWithoutEnumeratingTheSubnet() { + assertThat(ipAddrFilter.filter("2001:db8::42/64", interpreter, "0")) + .isEqualTo("2001:db8::/64"); + assertThat(ipv6Filter.filter("2001:db8::/64", interpreter, "1")) + .isEqualTo("2001:db8::1/64"); + assertThat(ipAddrFilter.filter("2001:db8::/64", interpreter, "-1")) + .isEqualTo("2001:db8::ffff:ffff:ffff:ffff/64"); + assertThat(ipAddrFilter.filter("::/0", interpreter, "-1")) + .isEqualTo("ffff:ffff:ffff:ffff:ffff:ffff:ffff:ffff/0"); + assertThat(ipAddrFilter.filter("2001:db8::/127", interpreter, "1")) + .isEqualTo("2001:db8::1/127"); + assertThat(ipAddrFilter.filter("::1/128", interpreter, "-1")).isEqualTo("::1/128"); + assertThat(ipAddrFilter.filter("::1/128", interpreter, "1")).isEqualTo("::1/128"); + } + + @Test + public void itRejectsOutOfRangeIndexes() { + for (String index : Arrays.asList( + "256", + "-257", + "99999999999999999999999999999999999999999" + )) { + assertThat(ipAddrFilter.filter("192.168.10.0/24", interpreter, index)) + .isEqualTo(false); + } + assertThat(ipAddrFilter.filter("2001:db8::/127", interpreter, "2")).isEqualTo(false); + assertThat( + ipAddrFilter.filter("::/0", interpreter, "340282366920938463463374607431768211456") + ) + .isEqualTo(false); + } + + @Test + public void itReturnsWildcardMasks() { + assertThat(ipAddrFilter.filter("192.168.10.42/24", interpreter, "wildcard")) + .isEqualTo("0.0.0.255"); + assertThat(ipv4Filter.filter("192.168.10.0/20", interpreter, "wildcard")) + .isEqualTo("0.0.15.255"); + assertThat(ipAddrFilter.filter("0.0.0.0/0", interpreter, "wildcard")) + .isEqualTo("255.255.255.255"); + assertThat(ipAddrFilter.filter("192.168.10.1/32", interpreter, "wildcard")) + .isEqualTo("0.0.0.0"); + assertThat(ipv6Filter.filter("2001:db8::/64", interpreter, "wildcard")) + .isEqualTo("::ffff:ffff:ffff:ffff"); + assertThat(ipAddrFilter.filter("::1/128", interpreter, "wildcard")).isEqualTo("::"); + assertThat(ipAddrFilter.filter("192.168.10.1", interpreter, "wildcard")) + .isEqualTo("0.0.0.0"); + assertThat(ipAddrFilter.filter("::1", interpreter, "wildcard")).isEqualTo("::"); + } + + @Test + public void itValidatesCidrForIndexesAndWildcardMasks() { + for (String query : Arrays.asList("0", "wildcard")) { + assertThatThrownBy(() -> ipAddrFilter.filter("192.168.10.1/33", interpreter, query)) + .isInstanceOf(InvalidArgumentException.class); + assertThatThrownBy(() -> ipAddrFilter.filter("2001:db8::/129", interpreter, query)) + .isInstanceOf(InvalidArgumentException.class); + } + } + + @Test + public void itSupportsIndexesAndWildcardMasksInTemplatesAndLists() { + assertThat( + interpreter.render( + "{{ '192.168.10.0/24' | ipaddr(1) | ipaddr('address') }} {{ '192.168.10.0/24' | ipaddr('wildcard') }}" + ) + ) + .isEqualTo("192.168.10.1 0.0.0.255"); + assertThat( + ipAddrFilter.filter( + Arrays.asList("192.168.10.0/24", "invalid", "2001:db8::/64"), + interpreter, + "1" + ) + ) + .isEqualTo(Arrays.asList("192.168.10.1/24", "2001:db8::1/64")); + } }