diff --git a/build.gradle b/build.gradle index 14352e17c..8222a4865 100644 --- a/build.gradle +++ b/build.gradle @@ -73,6 +73,7 @@ dependencies { implementation('info.picocli:picocli:4.7.6') implementation('com.fasterxml.jackson.datatype:jackson-datatype-jsr310:2.17.1') + implementation('com.fasterxml.jackson.dataformat:jackson-dataformat-yaml:2.17.1') implementation('com.github.ben-manes.caffeine:caffeine:3.1.8') diff --git a/src/main/java/com/mageddo/dataformat/yaml/YamlUtils.java b/src/main/java/com/mageddo/dataformat/yaml/YamlUtils.java new file mode 100644 index 000000000..8b7de4171 --- /dev/null +++ b/src/main/java/com/mageddo/dataformat/yaml/YamlUtils.java @@ -0,0 +1,39 @@ +package com.mageddo.dataformat.yaml; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.dataformat.yaml.YAMLMapper; + +import java.io.UncheckedIOException; + +public class YamlUtils { + + public static final YAMLMapper mapper = YAMLMapper + .builder() + .enable(SerializationFeature.INDENT_OUTPUT) + .build(); + + public static String format(String yaml) { + try { + return mapper.writeValueAsString(mapper.readTree(yaml)); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + public static String writeValueAsString(Object o) { + try { + return mapper.writeValueAsString(o); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } + + public static T readValue(String yaml, Class clazz) { + try { + return mapper.readValue(yaml, clazz); + } catch (JsonProcessingException e) { + throw new UncheckedIOException(e); + } + } +} diff --git a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/ConfigV3.java b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/ConfigV3.java new file mode 100644 index 000000000..74e43c99d --- /dev/null +++ b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/ConfigV3.java @@ -0,0 +1,125 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3; + +import lombok.Data; + +import java.util.List; + +@Data +public class ConfigV3 { + + public int version; + public Server server; + public Solver solver; + public DefaultDns defaultDns; + public Log log; + + @Data + public static class CircuitBreaker { + public String name; + } + + @Data + public static class DefaultDns { + public Boolean active; + public ResolvConf resolvConf; + } + + @Data + public static class Dns { + public Integer port; + public Integer noEntriesResponseCode; + } + + @Data + public static class Docker { + public Boolean registerContainerNames; + public String domain; + public Boolean hostMachineFallback; + public DpsNetwork dpsNetwork; +// public Networks networks; + public String dockerDaemonUri; + } + + @Data + public static class DpsNetwork { + public String name; + public Boolean autoCreate; + public Boolean autoConnect; + } + + @Data + public static class Env { + public String name; + public List hostnames; + } + + @Data + public static class Hostname { + public String type; + public String hostname; + public String ip; + public Integer ttl; + } + + @Data + public static class Local { + public String activeEnv; + public List envs; + } + + @Data + public static class Log { + public String level; + public String file; + } + + @Data + public static class Networks { + public List preferredNetworkNames; + } + + @Data + public static class Remote { + public Boolean active; + public List dnsServers; + public CircuitBreaker circuitBreaker; + } + + @Data + public static class ResolvConf { + public String paths; + public Boolean overrideNameServers; + } + + @Data + public static class Server { + public Dns dns; + public Web web; + public String protocol; + } + + @Data + public static class Solver { + public Remote remote; + public Docker docker; + public System system; + public Local local; + public Stub stub; + } + + @Data + public static class Stub { + public String domainName; + } + + @Data + public static class System { + public String hostMachineHostname; + } + + @Data + public static class Web { + public Integer port; + } + +} diff --git a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/Converter.java b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/Converter.java new file mode 100644 index 000000000..957815698 --- /dev/null +++ b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/Converter.java @@ -0,0 +1,13 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter; + +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; + +public interface Converter { + + ConfigV3 parse(); + + String serialize(ConfigV3 config); + + int priority(); + +} diff --git a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java new file mode 100644 index 000000000..146c162b6 --- /dev/null +++ b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverter.java @@ -0,0 +1,194 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter; + +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; +import com.mageddo.json.JsonUtils; +import org.apache.commons.lang3.StringUtils; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Locale; +import java.util.Map; + +public class EnvConverter implements Converter { + + static final String PREFIX = "DPS_"; + + @Override + public ConfigV3 parse() { + return parse(System.getenv()); + } + + ConfigV3 parse(Map envs) { + final var tree = buildTree(envs); + return JsonUtils.instance().convertValue(tree, ConfigV3.class); + } + + @Override + public String serialize(ConfigV3 config) { + return ""; + } + + @Override + public int priority() { + return 0; + } + + Map buildTree(Map envs) { + final Map root = new LinkedHashMap<>(); + envs.entrySet() + .stream() + .filter(entry -> entry.getKey().startsWith(PREFIX)) + .forEach(entry -> insert(root, ConfigV3.class, entry.getKey().substring(PREFIX.length()), entry.getValue())); + return root; + } + + private void insert(Map current, Class currentClass, String key, String value) { + final var tokens = key.split("_"); + assign(current, currentClass, tokens, 0, value); + } + + private void assign(Map current, Class currentClass, String[] tokens, int index, String value) { + final var match = findField(currentClass, tokens, index); + final var field = match.field(); + final var nextIndex = index + match.consumedTokens(); + + if (List.class.isAssignableFrom(field.getType())) { + final int listIndex = parseIndex(tokens, nextIndex); + final var list = (List) current.computeIfAbsent(field.getName(), k -> new ArrayList<>()); + ensureSize(list, listIndex + 1); + + final var elementType = match.elementType(); + if (isSimple(elementType)) { + list.set(listIndex, convert(value, elementType)); + return; + } + + Map nested = asMap(list.get(listIndex)); + if (nested == null) { + nested = new LinkedHashMap<>(); + list.set(listIndex, nested); + } + assign(nested, elementType, tokens, nextIndex + 1, value); + return; + } + + if (isSimple(field.getType())) { + current.put(field.getName(), convert(value, field.getType())); + return; + } + + final Map nested = (Map) current.computeIfAbsent(field.getName(), k -> new LinkedHashMap<>()); + assign(nested, field.getType(), tokens, nextIndex, value); + } + + private FieldMatch findField(Class currentClass, String[] tokens, int startIndex) { + for (int len = tokens.length - startIndex; len >= 1; len--) { + final var property = toProperty(tokens, startIndex, len); + final Field field = findField(currentClass, property); + if (field != null) { + final Class elementType; + if (List.class.isAssignableFrom(field.getType())) { + if (field.getGenericType() instanceof ParameterizedType parameterizedType) { + elementType = (Class) parameterizedType.getActualTypeArguments()[0]; + } else { + elementType = Object.class; + } + } else { + elementType = null; + } + return new FieldMatch(field, len, elementType); + } + } + throw new IllegalArgumentException("Unknown path for " + String.join("_", tokens)); + } + + private Field findField(Class clazz, String name) { + try { + return clazz.getField(name); + } catch (NoSuchFieldException e) { + return null; + } + } + + private String toProperty(String[] tokens, int startIndex, int len) { + final StringBuilder sb = new StringBuilder(); + for (int i = 0; i < len; i++) { + if (i > 0) { + sb.append('_'); + } + sb.append(tokens[startIndex + i]); + } + return toCamelCase(sb.toString()); + } + + private String toCamelCase(String value) { + final var parts = value.toLowerCase(Locale.US).split("_"); + final var sb = new StringBuilder(parts[0]); + for (int i = 1; i < parts.length; i++) { + sb.append(StringUtils.capitalize(parts[i])); + } + return sb.toString(); + } + + private boolean isSimple(Class type) { + if (type == null) { + return false; + } + return type.isPrimitive() + || Number.class.isAssignableFrom(type) + || Boolean.class.isAssignableFrom(type) + || CharSequence.class.isAssignableFrom(type); + } + + private Object convert(String value, Class type) { + if (StringUtils.isBlank(value) && type != String.class) { + return null; + } + if (type == String.class) { + return value; + } + if (type == Integer.class || type == int.class) { + return Integer.valueOf(value); + } + if (type == Boolean.class || type == boolean.class) { + return Boolean.valueOf(value); + } + if (type == Long.class || type == long.class) { + return Long.valueOf(value); + } + if (type == Double.class || type == double.class) { + return Double.valueOf(value); + } + if (type == Float.class || type == float.class) { + return Float.valueOf(value); + } + return value; + } + + private int parseIndex(String[] tokens, int index) { + if (index >= tokens.length) { + throw new IllegalArgumentException("Missing list index for " + String.join("_", tokens)); + } + return Integer.parseInt(tokens[index]); + } + + private void ensureSize(List list, int size) { + while (list.size() < size) { + list.add(null); + } + } + + @SuppressWarnings("unchecked") + private Map asMap(Object value) { + if (value instanceof Map map) { + return (Map) map; + } + return null; + } + + record FieldMatch(Field field, int consumedTokens, Class elementType) { + } +} diff --git a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/JsonConverter.java b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/JsonConverter.java new file mode 100644 index 000000000..56b9d23ce --- /dev/null +++ b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/JsonConverter.java @@ -0,0 +1,32 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter; + +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; +import com.mageddo.json.JsonUtils; +import lombok.NoArgsConstructor; + +import javax.inject.Inject; +import javax.inject.Singleton; + +@Singleton +@NoArgsConstructor(onConstructor_ = @Inject) +public class JsonConverter implements Converter { + + @Override + public ConfigV3 parse() { + return parse(""); + } + + public ConfigV3 parse(String json) { + return JsonUtils.readValue(json, ConfigV3.class); + } + + @Override + public String serialize(ConfigV3 config) { + return JsonUtils.prettyWriteValueAsString(config); + } + + @Override + public int priority() { + return 1; + } +} diff --git a/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/YamlConverter.java b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/YamlConverter.java new file mode 100644 index 000000000..73f9f201c --- /dev/null +++ b/src/main/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/YamlConverter.java @@ -0,0 +1,27 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter; + +import com.mageddo.dataformat.yaml.YamlUtils; +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; + +public class YamlConverter implements Converter { + + @Override + public ConfigV3 parse() { + return null; + } + + public ConfigV3 parse(String yaml) { + return YamlUtils.readValue(yaml, ConfigV3.class); + } + + @Override + public String serialize(ConfigV3 config) { + return YamlUtils.writeValueAsString(config); + } + + @Override + public int priority() { + return 2; + } + +} diff --git a/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverterTest.java b/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverterTest.java new file mode 100644 index 000000000..79df388c4 --- /dev/null +++ b/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/converter/EnvConverterTest.java @@ -0,0 +1,66 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter; + +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.templates.ConfigV3Templates; +import org.junit.jupiter.api.Test; + +import java.util.LinkedHashMap; +import java.util.Map; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +class EnvConverterTest { + + private final EnvConverter converter = new EnvConverter(); + + @Test + void shouldParseEnvironmentMatchingTemplate() { + + final Map env = new LinkedHashMap<>(); + env.put("DPS_VERSION", "3"); + env.put("DPS_SERVER_DNS_PORT", "53"); + env.put("DPS_SERVER_DNS_NO_ENTRIES_RESPONSE_CODE", "3"); + env.put("DPS_SERVER_WEB_PORT", "5380"); + env.put("DPS_SERVER_PROTOCOL", "UDP_TCP"); + env.put("DPS_SOLVER_REMOTE_ACTIVE", "true"); + env.put("DPS_SOLVER_REMOTE_DNS_SERVERS_0", "8.8.8.8"); + env.put("DPS_SOLVER_REMOTE_DNS_SERVERS_1", "4.4.4.4:53"); + env.put("DPS_SOLVER_REMOTE_CIRCUIT_BREAKER_NAME", "STATIC_THRESHOLD"); + env.put("DPS_SOLVER_DOCKER_REGISTER_CONTAINER_NAMES", "false"); + env.put("DPS_SOLVER_DOCKER_DOMAIN", "docker"); + env.put("DPS_SOLVER_DOCKER_HOST_MACHINE_FALLBACK", "true"); + env.put("DPS_SOLVER_DOCKER_DPS_NETWORK_NAME", "dps"); + env.put("DPS_SOLVER_DOCKER_DPS_NETWORK_AUTO_CREATE", "false"); + env.put("DPS_SOLVER_DOCKER_DPS_NETWORK_AUTO_CONNECT", "false"); + env.put("DPS_SOLVER_SYSTEM_HOST_MACHINE_HOSTNAME", "host.docker"); + env.put("DPS_SOLVER_LOCAL_ACTIVE_ENV", ""); + env.put("DPS_SOLVER_LOCAL_ENVS_0_NAME", ""); + env.put("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_TYPE", "A"); + env.put("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_HOSTNAME", "github.com"); + env.put("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_IP", "192.168.0.1"); + env.put("DPS_SOLVER_LOCAL_ENVS_0_HOSTNAMES_0_TTL", "255"); + env.put("DPS_SOLVER_STUB_DOMAIN_NAME", "stub"); + env.put("DPS_DEFAULT_DNS_ACTIVE", "true"); + env.put("DPS_DEFAULT_DNS_RESOLV_CONF_PATHS", "/host/etc/systemd/resolved.conf,/host/etc/resolv.conf,/etc/systemd/resolved.conf,/etc/resolv.conf"); + env.put("DPS_DEFAULT_DNS_RESOLV_CONF_OVERRIDE_NAME_SERVERS", "true"); + env.put("DPS_LOG_LEVEL", "DEBUG"); + env.put("DPS_LOG_FILE", "console"); + + final ConfigV3 expected = new ConfigV3Templates().build(); + final ConfigV3 parsed = converter.parse(env); + + assertEquals(expected, parsed); + } + + @Test + void shouldBuildTreeWithNestedStructure() { + final Map env = Map.of("DPS_SOLVER_DOCKER_DPS_NETWORK_AUTO_CONNECT", "true"); + + final Map tree = converter.buildTree(env); + + assertEquals( + Map.of("solver", Map.of("docker", Map.of("dpsNetwork", Map.of("autoConnect", true)))), + tree + ); + } +} diff --git a/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/templates/ConfigV3Templates.java b/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/templates/ConfigV3Templates.java new file mode 100644 index 000000000..3700231e2 --- /dev/null +++ b/src/test/java/com/mageddo/dnsproxyserver/config/provider/dataformatv3/templates/ConfigV3Templates.java @@ -0,0 +1,139 @@ +package com.mageddo.dnsproxyserver.config.provider.dataformatv3.templates; + +import com.mageddo.dataformat.yaml.YamlUtils; +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.ConfigV3; +import com.mageddo.dnsproxyserver.config.provider.dataformatv3.converter.JsonConverter; +import com.mageddo.json.JsonUtils; + +public class ConfigV3Templates { + + public static String buildYaml() { + return YamlUtils.format(""" + --- + version: 3 + server: + dns: + port: 53 + noEntriesResponseCode: 3 + web: + port: 5380 + protocol: UDP_TCP + solver: + remote: + active: true + dnsServers: + - 8.8.8.8 + - 4.4.4.4:53 + circuitBreaker: + name: STATIC_THRESHOLD + docker: + registerContainerNames: false + domain: docker + hostMachineFallback: true + dpsNetwork: + name: dps + autoCreate: false + autoConnect: false + dockerDaemonUri: \ + system: + hostMachineHostname: host.docker + local: + activeEnv: '' + envs: + - name: '' + hostnames: + - type: A + hostname: github.com + ip: 192.168.0.1 + ttl: 255 + stub: + domainName: stub + defaultDns: + active: true + resolvConf: + paths: "/host/etc/systemd/resolved.conf,/host/etc/resolv.conf,/etc/systemd/resolved.conf,/etc/resolv.conf" + overrideNameServers: true + log: + level: DEBUG + file: console + """); + } + + public ConfigV3 build() { + return new JsonConverter().parse(buildJson()); + } + + public static String buildJson() { + return JsonUtils.prettify(""" + { + "version": 3, + "server": { + "dns": { + "port": 53, + "noEntriesResponseCode": 3 + }, + "web": { + "port": 5380 + }, + "protocol": "UDP_TCP" + }, + "solver": { + "remote": { + "active": true, + "dnsServers": [ + "8.8.8.8", "4.4.4.4:53" + ], + "circuitBreaker": { + "name": "STATIC_THRESHOLD" + } + }, + "docker": { + "registerContainerNames": false, + "domain": "docker", + "hostMachineFallback": true, + "dpsNetwork": { + "name": "dps", + "autoCreate": false, + "autoConnect": false + }, + "dockerDaemonUri": null + }, + "system": { + "hostMachineHostname": "host.docker" + }, + "local": { + "activeEnv": "", + "envs": [ + { + "name": "", + "hostnames": [ + { + "type": "A", + "hostname": "github.com", + "ip": "192.168.0.1", + "ttl": 255 + } + ] + } + ] + }, + "stub": { + "domainName": "stub" + } + }, + "defaultDns": { + "active": true, + "resolvConf": { + "paths": "/host/etc/systemd/resolved.conf,/host/etc/resolv.conf,/etc/systemd/resolved.conf,/etc/resolv.conf", + "overrideNameServers": true + } + }, + "log": { + "level": "DEBUG", + "file": "console" + } + } + """); + } + +}