diff --git a/src/main/java/com/uid2/optout/vertx/OptOutTrafficFilter.java b/src/main/java/com/uid2/optout/vertx/OptOutTrafficFilter.java new file mode 100644 index 0000000..e8bd04b --- /dev/null +++ b/src/main/java/com/uid2/optout/vertx/OptOutTrafficFilter.java @@ -0,0 +1,172 @@ +package com.uid2.optout.vertx; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.ArrayList; +import java.util.List; +import java.util.Collections; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Paths; +import java.nio.charset.StandardCharsets; +import io.vertx.core.json.JsonObject; +import io.vertx.core.json.JsonArray; + +public class OptOutTrafficFilter { + private static final Logger LOGGER = LoggerFactory.getLogger(OptOutTrafficFilter.class); + + private final String trafficFilterConfigPath; + List filterRules; + + /** + * Traffic filter rule defining a time range and a list of IP addresses to exclude + */ + private static class TrafficFilterRule { + private final List range; + private final List ipAddresses; + + TrafficFilterRule(List range, List ipAddresses) { + this.range = range; + this.ipAddresses = ipAddresses; + } + + public long getRangeStart() { + return range.get(0); + } + public long getRangeEnd() { + return range.get(1); + } + public List getIpAddresses() { + return ipAddresses; + } + } + + public static class MalformedTrafficFilterConfigException extends Exception { + public MalformedTrafficFilterConfigException(String message) { + super(message); + } + } + + /** + * Constructor for OptOutTrafficFilter + * + * @param trafficFilterConfigPath S3 path for traffic filter config + * @throws MalformedTrafficFilterConfigException if the traffic filter config is invalid + */ + public OptOutTrafficFilter(String trafficFilterConfigPath) throws MalformedTrafficFilterConfigException { + this.trafficFilterConfigPath = trafficFilterConfigPath; + // Initial filter rules load + this.filterRules = Collections.emptyList(); // start empty + reloadTrafficFilterConfig(); // load ConfigMap + + LOGGER.info("OptOutTrafficFilter initialized: filterRules={}", + filterRules.size()); + } + + /** + * Reload traffic filter config from ConfigMap. + * Expected format: + * { + * "denylist_requests": [ + * {range: [startTimestamp, endTimestamp], IPs: ["ip1"]}, + * {range: [startTimestamp, endTimestamp], IPs: ["ip1", "ip2"]}, + * {range: [startTimestamp, endTimestamp], IPs: ["ip1", "ip3"]}, + * ] + * } + * + * Can be called periodically to pick up config changes without restarting. + */ + public void reloadTrafficFilterConfig() throws MalformedTrafficFilterConfigException { + LOGGER.info("Loading traffic filter config from ConfigMap"); + try (InputStream is = Files.newInputStream(Paths.get(trafficFilterConfigPath))) { + String content = new String(is.readAllBytes(), StandardCharsets.UTF_8); + JsonObject filterConfigJson = new JsonObject(content); + + this.filterRules = parseFilterRules(filterConfigJson); + + LOGGER.info("Successfully loaded traffic filter config from ConfigMap: filterRules={}", + filterRules.size()); + + } catch (Exception e) { + LOGGER.warn("No traffic filter config found at: {}", trafficFilterConfigPath, e); + throw new MalformedTrafficFilterConfigException(e.getMessage()); + } + } + + /** + * Parse request filtering rules from JSON config + */ + List parseFilterRules(JsonObject config) throws MalformedTrafficFilterConfigException { + List rules = new ArrayList<>(); + try { + JsonArray denylistRequests = config.getJsonArray("denylist_requests"); + if (denylistRequests == null) { + LOGGER.error("Invalid traffic filter config: denylist_requests is null"); + throw new MalformedTrafficFilterConfigException("Invalid traffic filter config: denylist_requests is null"); + } + for (int i = 0; i < denylistRequests.size(); i++) { + JsonObject ruleJson = denylistRequests.getJsonObject(i); + + // parse range + var rangeJson = ruleJson.getJsonArray("range"); + List range = new ArrayList<>(); + if (rangeJson != null && rangeJson.size() == 2) { + long start = rangeJson.getLong(0); + long end = rangeJson.getLong(1); + + if (start >= end) { + LOGGER.error("Invalid traffic filter rule: range start must be less than end: {}", ruleJson.encode()); + throw new MalformedTrafficFilterConfigException("Invalid traffic filter rule: range start must be less than end"); + } + range.add(start); + range.add(end); + } + + // parse IPs + var ipAddressesJson = ruleJson.getJsonArray("IPs"); + List ipAddresses = new ArrayList<>(); + if (ipAddressesJson != null) { + for (int j = 0; j < ipAddressesJson.size(); j++) { + ipAddresses.add(ipAddressesJson.getString(j)); + } + } + + // log error and throw exception if rule is invalid + if (range.size() != 2 || ipAddresses.size() == 0 || range.get(1) - range.get(0) > 86400) { // range must be 24 hours or less + LOGGER.error("Invalid traffic filter rule, range must be 24 hours or less: {}", ruleJson.encode()); + throw new MalformedTrafficFilterConfigException("Invalid traffic filter rule, range must be 24 hours or less"); + } + + TrafficFilterRule rule = new TrafficFilterRule(range, ipAddresses); + + LOGGER.info("Loaded traffic filter rule: range=[{}, {}], IPs={}", rule.getRangeStart(), rule.getRangeEnd(), rule.getIpAddresses()); + rules.add(rule); + } + return rules; + } catch (Exception e) { + LOGGER.error("Failed to parse traffic filter rules: config={}, error={}", config.encode(), e.getMessage()); + throw new MalformedTrafficFilterConfigException(e.getMessage()); + } + } + + public boolean isDenylisted(SqsParsedMessage message) { + long timestamp = message.getTimestamp(); + String clientIp = message.getClientIp(); + + if (clientIp == null || clientIp.isEmpty()) { + LOGGER.error("Request does not contain client IP, timestamp={}", timestamp); + return false; + } + + for (TrafficFilterRule rule : filterRules) { + if(timestamp >= rule.getRangeStart() && timestamp <= rule.getRangeEnd()) { + if(rule.getIpAddresses().contains(clientIp)) { + return true; + } + }; + } + return false; + } + +} \ No newline at end of file diff --git a/src/test/java/com/uid2/optout/vertx/OptOutTrafficFilterTest.java b/src/test/java/com/uid2/optout/vertx/OptOutTrafficFilterTest.java new file mode 100644 index 0000000..63f6807 --- /dev/null +++ b/src/test/java/com/uid2/optout/vertx/OptOutTrafficFilterTest.java @@ -0,0 +1,424 @@ +package com.uid2.optout.vertx; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import software.amazon.awssdk.services.sqs.model.Message; + +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.Assert.*; + +public class OptOutTrafficFilterTest { + + private static final String TEST_CONFIG_PATH = "./traffic-config.json"; + + @Before + public void setUp() { + try { + Files.deleteIfExists(Path.of(TEST_CONFIG_PATH)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @After + public void tearDown() { + try { + Files.deleteIfExists(Path.of(TEST_CONFIG_PATH)); + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @Test + public void testParseFilterRules_emptyRules() throws Exception { + // Setup - empty denylist + String config = """ + { + "denylist_requests": [] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - no rules + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + assertEquals(0, filter.filterRules.size()); + } + + @Test + public void testParseFilterRules_singleRule() throws Exception { + // Setup - config with one rule + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - one rule + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + assertEquals(1, filter.filterRules.size()); + } + + @Test + public void testParseFilterRules_multipleRules() throws Exception { + // Setup - config with multiple rules + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + }, + { + "range": [1700010000, 1700013600], + "IPs": ["10.0.0.1", "10.0.0.2"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - two rules + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + assertEquals(2, filter.filterRules.size()); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testParseFilterRules_missingDenylistRequests() throws Exception { + // Setup - config without denylist_requests field + String config = """ + { + "other_field": "value" + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - throws exception + new OptOutTrafficFilter(TEST_CONFIG_PATH); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testParseFilterRules_invalidRange_startAfterEnd() throws Exception { + // Setup - range where start > end + String config = """ + { + "denylist_requests": [ + { + "range": [1700003600, 1700000000], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - throws exception + new OptOutTrafficFilter(TEST_CONFIG_PATH); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testParseFilterRules_invalidRange_startEqualsEnd() throws Exception { + // Setup - range where start == end + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700000000], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - throws exception + new OptOutTrafficFilter(TEST_CONFIG_PATH); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testParseFilterRules_rangeExceeds24Hours() throws Exception { + // Setup - range longer than 24 hours (86400 seconds) + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700086401], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - throws exception + new OptOutTrafficFilter(TEST_CONFIG_PATH); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testParseFilterRules_emptyIPs() throws Exception { + // Setup - rule with empty IP list + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": [] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - throws exception + new OptOutTrafficFilter(TEST_CONFIG_PATH); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testParseFilterRules_missingIPs() throws Exception { + // Setup - rule without IPs field + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - throws exception + new OptOutTrafficFilter(TEST_CONFIG_PATH); + } + + @Test + public void testIsDenylisted_matchingIPAndTimestamp() throws Exception { + // Setup - filter with denylist rule + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1", "10.0.0.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + SqsParsedMessage message = createTestMessage(1700001800, "192.168.1.1"); + + // Act & Assert - denylisted + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + assertTrue(filter.isDenylisted(message)); + } + + @Test + public void testIsDenylisted_matchingIPOutsideTimeRange() throws Exception { + // Setup - filter with denylist rule + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + + // Act & Assert - message before range not denylisted + SqsParsedMessage messageBefore = createTestMessage(1699999999, "192.168.1.1"); + assertFalse(filter.isDenylisted(messageBefore)); + // Act & Assert - message after range not denylisted + SqsParsedMessage messageAfter = createTestMessage(1700003601, "192.168.1.1"); + assertFalse(filter.isDenylisted(messageAfter)); + } + + @Test + public void testIsDenylisted_nonMatchingIP() throws Exception { + // Setup - filter with denylist rule + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + + // Act & Assert - non-matching IP not denylisted + SqsParsedMessage message = createTestMessage(1700001800, "10.0.0.1"); + assertFalse(filter.isDenylisted(message)); + } + + @Test + public void testIsDenylisted_atRangeBoundaries() throws Exception { + // Setup - filter with denylist rule + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + + // Act & Assert - message at start boundary (inclusive) denylisted + SqsParsedMessage messageAtStart = createTestMessage(1700000000, "192.168.1.1"); + assertTrue(filter.isDenylisted(messageAtStart)); + + // Act & Assert - message at end boundary (inclusive) denylisted + SqsParsedMessage messageAtEnd = createTestMessage(1700003600, "192.168.1.1"); + assertTrue(filter.isDenylisted(messageAtEnd)); + } + + @Test + public void testIsDenylisted_multipleRules() throws Exception { + // Setup - multiple denylist rules + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + }, + { + "range": [1700010000, 1700013600], + "IPs": ["10.0.0.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + + // Act & Assert - message matches first rule + SqsParsedMessage msg1 = createTestMessage(1700001800, "192.168.1.1"); + assertTrue(filter.isDenylisted(msg1)); + + // Act & Assert - message matches second rule + SqsParsedMessage msg2 = createTestMessage(1700011800, "10.0.0.1"); + assertTrue(filter.isDenylisted(msg2)); + + // Act & Assert - message matches neither rule + SqsParsedMessage msg3 = createTestMessage(1700005000, "172.16.0.1"); + assertFalse(filter.isDenylisted(msg3)); + } + + @Test + public void testIsDenylisted_nullClientIp() throws Exception { + // Setup - filter with denylist rule + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + + // Act & Assert - message with null IP not denylisted + SqsParsedMessage message = createTestMessage(1700001800, null); + assertFalse(filter.isDenylisted(message)); + } + + @Test + public void testReloadTrafficFilterConfig_success() throws Exception { + // Setup - config with one rule + String initialConfig = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), initialConfig); + + // Act & Assert - one rule + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + assertEquals(1, filter.filterRules.size()); + + // Setup - update config + String updatedConfig = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700003600], + "IPs": ["192.168.1.1"] + }, + { + "range": [1700010000, 1700013600], + "IPs": ["10.0.0.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), updatedConfig); + + // Act & Assert - two rules + filter.reloadTrafficFilterConfig(); + assertEquals(2, filter.filterRules.size()); + } + + @Test(expected = OptOutTrafficFilter.MalformedTrafficFilterConfigException.class) + public void testReloadTrafficFilterConfig_fileNotFound() throws Exception { + // Setup, Act & Assert - try to create filter with non-existent config + new OptOutTrafficFilter("./non-existent-file.json"); + } + + @Test + public void testParseFilterRules_maxValidRange() throws Exception { + // Setup - range exactly 24 hours (86400 seconds) - should be valid + String config = """ + { + "denylist_requests": [ + { + "range": [1700000000, 1700086400], + "IPs": ["192.168.1.1"] + } + ] + } + """; + Files.writeString(Path.of(TEST_CONFIG_PATH), config); + + // Act & Assert - one rule + OptOutTrafficFilter filter = new OptOutTrafficFilter(TEST_CONFIG_PATH); + assertEquals(1, filter.filterRules.size()); + } + + /** + * Helper method to create test SqsParsedMessage + */ + private SqsParsedMessage createTestMessage(long timestamp, String clientIp) { + Message mockMessage = Message.builder().build(); + byte[] hash = new byte[32]; + byte[] id = new byte[32]; + return new SqsParsedMessage(mockMessage, hash, id, timestamp, null, null, clientIp, null); + } +}