From a7d586f0f784f2d3ed143fe94409ac9e5cfdeb17 Mon Sep 17 00:00:00 2001
From: Michael Hoennig <michael.hoennig@hostsharing.net>
Date: Tue, 10 Sep 2024 13:15:03 +0200
Subject: [PATCH] check-domain-setup-permission (#97)

Co-authored-by: Michael Hoennig <michael@hoennig.de>
Reviewed-on: https://dev.hostsharing.net/hostsharing/hs.hsadmin.ng/pulls/97
Reviewed-by: Marc Sandlus <marc.sandlus@hostsharing.net>
---
 .../HsDomainSetupBookingItemValidator.java    |  51 ++-
 .../hs/hosting/asset/validators/Dns.java      | 134 +++++++
 .../HsDomainSetupHostingAssetValidator.java   | 107 +++--
 .../hs/validation/StringProperty.java         |  84 +++-
 ...mainSetupBookingItemValidatorUnitTest.java | 155 +++++++
 ...sHostingAssetControllerAcceptanceTest.java |  10 +
 .../hosting/asset/validators/DnsUnitTest.java |  29 ++
 ...ttpSetupHostingAssetValidatorUnitTest.java |   8 +-
 ...ainSetupHostingAssetValidatorUnitTest.java | 378 ++++++++++++++++--
 ...lAddressHostingAssetValidatorUnitTest.java |   4 +-
 ...UnixUserHostingAssetValidatorUnitTest.java |   2 +-
 .../hs/migration/BaseOfficeDataImport.java    |   2 +-
 .../hs/migration/ImportHostingAssets.java     |   4 +-
 13 files changed, 884 insertions(+), 84 deletions(-)
 create mode 100644 src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java
 create mode 100644 src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java

diff --git a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java
index a48ed4a5..3d62b765 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidator.java
@@ -1,10 +1,59 @@
 package net.hostsharing.hsadminng.hs.booking.item.validators;
 
+import net.hostsharing.hsadminng.hs.booking.item.HsBookingItem;
+import net.hostsharing.hsadminng.hs.validation.PropertiesProvider;
+
+import jakarta.persistence.EntityManager;
+import java.security.SecureRandom;
+import java.util.ArrayList;
+import java.util.List;
+
+import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.REGISTRAR_LEVEL_DOMAINS;
+import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;
+
 class HsDomainSetupBookingItemValidator extends HsBookingItemEntityValidator {
 
+    public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
+    public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
+    public static final String VERIFICATION_CODE_PROPERTY_NAME = "verificationCode";
+
     HsDomainSetupBookingItemValidator() {
         super(
-                // no properties yet. maybe later, the setup code goes here?
+                stringProperty(DOMAIN_NAME_PROPERTY_NAME).writeOnce()
+                        .maxLength(253)
+                        .matchesRegEx(FQDN_REGEX).describedAs("is not a (non-top-level) fully qualified domain name")
+                        .notMatchesRegEx(REGISTRAR_LEVEL_DOMAINS).describedAs("is a forbidden registrar-level domain name")
+                        .required(),
+                stringProperty(VERIFICATION_CODE_PROPERTY_NAME)
+                        .readOnly().initializedBy(HsDomainSetupBookingItemValidator::generateVerificationCode)
+
         );
     }
+
+    @Override
+    public List<String> validateEntity(final HsBookingItem bookingItem) {
+        final var violations = new ArrayList<String>();
+        final var domainName = bookingItem.getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
+        if (!bookingItem.isLoaded() &&
+                domainName.matches("hostsharing.(com|net|org|coop|de)")) {
+            violations.add("'" + bookingItem.toShortString() + ".resources." + DOMAIN_NAME_PROPERTY_NAME + "' = '" + domainName
+                    + "' is a forbidden Hostsharing domain name");
+        }
+        violations.addAll(super.validateEntity(bookingItem));
+        return violations;
+    }
+
+    private static String generateVerificationCode(final EntityManager em, final PropertiesProvider propertiesProvider) {
+        final var alphaNumeric = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789";
+        final var secureRandom = new SecureRandom();
+        final var sb = new StringBuilder();
+        for (int i = 0; i < 40; ++i) {
+            if ( i > 0 && i % 4 == 0 ) {
+                sb.append("-");
+            }
+            sb.append(alphaNumeric.charAt(secureRandom.nextInt(alphaNumeric.length())));
+        }
+        return sb.toString();
+    }
+
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java
new file mode 100644
index 00000000..037b95c0
--- /dev/null
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/Dns.java
@@ -0,0 +1,134 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import net.hostsharing.hsadminng.mapper.Array;
+import org.apache.commons.collections4.EnumerationUtils;
+
+import javax.naming.InvalidNameException;
+import javax.naming.NameNotFoundException;
+import javax.naming.NamingEnumeration;
+import javax.naming.NamingException;
+import javax.naming.ServiceUnavailableException;
+import javax.naming.directory.Attribute;
+import javax.naming.directory.InitialDirContext;
+import java.util.HashMap;
+import java.util.Hashtable;
+import java.util.List;
+import java.util.Map;
+import java.util.Optional;
+import java.util.regex.Pattern;
+
+import static java.util.Arrays.stream;
+import static java.util.Collections.emptyList;
+
+public class Dns {
+
+    public static final String[] REGISTRAR_LEVEL_DOMAINS = Array.of(
+            "[^.]+", // top-level-domains
+            "(co|org|gov|ac|sch)\\.uk",
+            "(com|net|org|edu|gov|asn|id)\\.au",
+            "(co|ne|or|ac|go)\\.jp",
+            "(com|net|org|gov|edu|ac)\\.cn",
+            "(com|net|org|gov|edu|mil|art)\\.br",
+            "(co|net|org|gen|firm|ind)\\.in",
+            "(com|net|org|gob|edu)\\.mx",
+            "(gov|edu)\\.it",
+            "(co|net|org|govt|ac|school|geek|kiwi)\\.nz",
+            "(co|ne|or|go|re|pe)\\.kr"
+    );
+    public static final Pattern[] REGISTRAR_LEVEL_DOMAIN_PATTERN = stream(REGISTRAR_LEVEL_DOMAINS)
+            .map(Pattern::compile)
+            .toArray(Pattern[]::new);
+
+    private final static Map<String, Result> fakeResults = new HashMap<>();
+
+    public static Optional<String> superDomain(final String domainName) {
+        final var parts = domainName.split("\\.", 2);
+        if (parts.length == 2) {
+            return Optional.of(parts[1]);
+        }
+        return Optional.empty();
+    }
+
+    public static boolean isRegistrarLevelDomain(final String domainName) {
+        return stream(REGISTRAR_LEVEL_DOMAIN_PATTERN)
+                .anyMatch(p -> p.matcher(domainName).matches());
+    }
+
+    /**
+     * @param domainName a fully qualified domain name
+     * @return true if `domainName` can be registered at a registrar, false if it's a subdomain of such or a registrar-level domain itself
+     */
+    public static boolean isRegistrableDomain(final String domainName) {
+        return !isRegistrarLevelDomain(domainName) &&
+                superDomain(domainName).map(Dns::isRegistrarLevelDomain).orElse(false);
+    }
+
+    public static void fakeResultForDomain(final String domainName, final Result fakeResult) {
+        fakeResults.put(domainName, fakeResult);
+    }
+
+    public static void resetFakeResults() {
+        fakeResults.clear();
+    }
+
+    public enum Status {
+        SUCCESS,
+        NAME_NOT_FOUND,
+        INVALID_NAME,
+        SERVICE_UNAVAILABLE,
+        UNKNOWN_FAILURE
+    }
+
+    public record Result(Status status, List<String> records, NamingException exception) {
+
+
+        public static Result fromRecords(final NamingEnumeration<?> recordEnumeration) {
+            final List<String> records = recordEnumeration == null
+                    ? emptyList()
+                    : EnumerationUtils.toList(recordEnumeration).stream().map(Object::toString).toList();
+            return new Result(Status.SUCCESS, records, null);
+        }
+
+        public static Result fromRecords(final String... records) {
+            return new Result(Status.SUCCESS, stream(records).toList(), null);
+        }
+
+        public static Result fromException(final NamingException exception) {
+            return switch (exception) {
+                case ServiceUnavailableException exc -> new Result(Status.SERVICE_UNAVAILABLE, emptyList(), exc);
+                case NameNotFoundException exc -> new Result(Status.NAME_NOT_FOUND, emptyList(), exc);
+                case InvalidNameException exc -> new Result(Status.INVALID_NAME, emptyList(), exc);
+                case NamingException exc -> new Result(Status.UNKNOWN_FAILURE, emptyList(), exc);
+            };
+        }
+    }
+
+    private final String domainName;
+
+    public Dns(final String domainName) {
+        this.domainName = domainName;
+    }
+
+    public Result fetchRecordsOfType(final String recordType) {
+        if (fakeResults.containsKey(domainName)) {
+            return fakeResults.get(domainName);
+        }
+
+        try {
+            final var env = new Hashtable<>();
+            env.put("java.naming.factory.initial", "com.sun.jndi.dns.DnsContextFactory");
+            final Attribute records = new InitialDirContext(env)
+                    .getAttributes(domainName, new String[] { recordType })
+                    .get(recordType);
+            return Result.fromRecords(records != null ? records.getAll() : null);
+        } catch (final NamingException exception) {
+            return Result.fromException(exception);
+        }
+    }
+
+    public static void main(String[] args) {
+        final var result = new Dns("example.org").fetchRecordsOfType("TXT");
+        System.out.println(result);
+    }
+
+}
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java
index 8701d2fe..40530ad1 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidator.java
@@ -3,55 +3,104 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
 import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
 
 import java.util.List;
+import java.util.Optional;
+import java.util.function.Supplier;
 import java.util.regex.Pattern;
 
 import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns.superDomain;
+import static net.hostsharing.hsadminng.hs.hosting.asset.validators.HsDomainHttpSetupHostingAssetValidator.SUBDOMAIN_NAME_REGEX;
 
 class HsDomainSetupHostingAssetValidator extends HostingAssetEntityValidator {
 
     public static final String FQDN_REGEX = "^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}";
-
-    private final Pattern identifierPattern;
+    public static final String DOMAIN_NAME_PROPERTY_NAME = "domainName";
 
     HsDomainSetupHostingAssetValidator() {
-        super(  DOMAIN_SETUP,
+        super(
+                DOMAIN_SETUP,
                 AlarmContact.isOptional(),
 
                 NO_EXTRA_PROPERTIES);
-        this.identifierPattern = Pattern.compile(FQDN_REGEX);
     }
 
     @Override
     public List<String> validateEntity(final HsHostingAsset assetEntity) {
-        // TODO.impl: for newly created entities, check the permission of setting up a domain
-        //
-        // reject, if the domain is any of these:
-        //  hostsharing.com|net|org|coop, // just to be on the safe side
-        //  [^.}+, // top-level-domain
-        //   co.uk, org.uk, gov.uk, ac.uk, sch.uk,
-        //   com.au, net.au, org.au, edu.au, gov.au, asn.au, id.au,
-        //   co.jp, ne.jp, or.jp, ac.jp, go.jp,
-        //   com.cn, net.cn, org.cn, gov.cn, edu.cn, ac.cn,
-        //   com.br, net.br, org.br, gov.br, edu.br, mil.br, art.br,
-        //   co.in, net.in, org.in, gen.in, firm.in, ind.in,
-        //   com.mx, net.mx, org.mx, gob.mx, edu.mx,
-        //   gov.it, edu.it,
-        //   co.nz, net.nz, org.nz, govt.nz, ac.nz, school.nz, geek.nz, kiwi.nz,
-        //   co.kr, ne.kr, or.kr, go.kr, re.kr, pe.kr
-        //
-        // allow if
-        //  - user has Admin/Agent-role for all its sub-domains and the direct parent-Domain which are set up at at Hostsharing
-        //  - domain has DNS zone with TXT record approval
-        //  - parent-domain has DNS zone with TXT record approval
-        //
-        // TXT-Record check:
-        // new InitialDirContext().getAttributes("dns:_netblocks.google.com", new String[] { "TXT"}).get("TXT").getAll();
+        final var violations = // new ArrayList<String>();
+            super.validateEntity(assetEntity);
+        if (!violations.isEmpty()) {
+            return violations;
+        }
 
-        return super.validateEntity(assetEntity);
+        final var domainName = assetEntity.getIdentifier();
+        final var dnsResult = new Dns(domainName).fetchRecordsOfType("TXT");
+        final Supplier<String> getCode = () -> assetEntity.getBookingItem().getDirectValue("verificationCode", String.class);
+        switch (dnsResult.status()) {
+            case Dns.Status.SUCCESS: {
+                final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
+                final var verificationFound = findTxtRecord(dnsResult, expectedTxtRecordValue)
+                        .or(() -> superDomain(domainName)
+                                .flatMap(superDomainName -> findTxtRecord(
+                                        new Dns(superDomainName).fetchRecordsOfType("TXT"),
+                                        expectedTxtRecordValue))
+                        );
+                if (verificationFound.isEmpty()) {
+                    violations.add(
+                            "[DNS] no TXT record '" + expectedTxtRecordValue +
+                                    "' found for domain name '" + domainName + "' (nor in its super-domain)");
+                }
+                break;
+            }
+
+            case Dns.Status.NAME_NOT_FOUND: {
+                if (isDnsVerificationRequiredForUnregisteredDomain(assetEntity)) {
+                    final var superDomain = superDomain(domainName);
+                    final var expectedTxtRecordValue = "Hostsharing-domain-setup-verification-code=" + getCode.get();
+                    final var verificationFoundInSuperDomain = superDomain.flatMap(superDomainName -> findTxtRecord(
+                            new Dns(superDomainName).fetchRecordsOfType("TXT"),
+                            expectedTxtRecordValue));
+                    if (verificationFoundInSuperDomain.isEmpty()) {
+                        violations.add(
+                                "[DNS] no TXT record '" + expectedTxtRecordValue +
+                                        "' found for domain name '" + superDomain.orElseThrow() + "'");
+                    }
+                }
+                // otherwise no DNS verification to be able to setup DNS for domains to register
+                break;
+            }
+
+            case Dns.Status.INVALID_NAME:
+                violations.add("[DNS] invalid domain name '" + assetEntity.getIdentifier() + "'");
+                break;
+
+            case Dns.Status.SERVICE_UNAVAILABLE:
+            case Dns.Status.UNKNOWN_FAILURE:
+                violations.add("[DNS] lookup failed for domain name '" + assetEntity.getIdentifier() + "': " + dnsResult.exception());
+                break;
+        }
+        return violations;
     }
 
     @Override
     protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
-        return identifierPattern;
+        if (assetEntity.getBookingItem() != null) {
+            final var bookingItemDomainName = assetEntity.getBookingItem()
+                    .getDirectValue(DOMAIN_NAME_PROPERTY_NAME, String.class);
+            return Pattern.compile(bookingItemDomainName, Pattern.CASE_INSENSITIVE | Pattern.LITERAL);
+        }
+        final var parentDomainName = assetEntity.getParentAsset().getIdentifier();
+        return Pattern.compile(SUBDOMAIN_NAME_REGEX + "\\." + parentDomainName.replace(".", "\\."), Pattern.CASE_INSENSITIVE);
+    }
+
+    private static boolean isDnsVerificationRequiredForUnregisteredDomain(final HsHostingAsset assetEntity) {
+        return !Dns.isRegistrableDomain(assetEntity.getIdentifier())
+                && assetEntity.getParentAsset() == null;
+    }
+
+
+    private static Optional<String> findTxtRecord(final Dns.Result result, final String expectedTxtRecordValue) {
+        return result.records().stream()
+                .filter(r -> r.contains(expectedTxtRecordValue))
+                .findAny();
     }
 }
diff --git a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java
index f9a27e85..6dc463d6 100644
--- a/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java
+++ b/src/main/java/net/hostsharing/hsadminng/hs/validation/StringProperty.java
@@ -1,10 +1,12 @@
 package net.hostsharing.hsadminng.hs.validation;
 
+import lombok.AccessLevel;
 import lombok.Setter;
 import net.hostsharing.hsadminng.mapper.Array;
 
 import java.util.Arrays;
 import java.util.List;
+import java.util.function.Consumer;
 import java.util.regex.Matcher;
 import java.util.regex.Pattern;
 
@@ -15,11 +17,19 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
 
     protected static final String[] KEY_ORDER = Array.join(
             ValidatableProperty.KEY_ORDER_HEAD,
-            Array.of("matchesRegEx", "minLength", "maxLength", "provided"),
+            Array.of("matchesRegEx", "matchesRegExDescription",
+                    "notMatchesRegEx", "notMatchesRegExDescription",
+                    "minLength", "maxLength",
+                    "provided"),
             ValidatableProperty.KEY_ORDER_TAIL,
             Array.of("undisclosed"));
     private String[] provided;
     private Pattern[] matchesRegEx;
+    private String matchesRegExDescription;
+    private Pattern[] notMatchesRegEx;
+    private String notMatchesRegExDescription;
+    @Setter(AccessLevel.PRIVATE)
+    private Consumer<String> describedAsConsumer;
     private Integer minLength;
     private Integer maxLength;
     private boolean undisclosed;
@@ -56,10 +66,23 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
 
     public P matchesRegEx(final String... regExPattern) {
         this.matchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new);
+        this.describedAsConsumer = violationMessage -> matchesRegExDescription = violationMessage;
         return self();
     }
 
-    /// predifined values, similar to fixed values in a combobox
+    public P notMatchesRegEx(final String... regExPattern) {
+        this.notMatchesRegEx = stream(regExPattern).map(Pattern::compile).toArray(Pattern[]::new);
+        this.describedAsConsumer = violationMessage -> notMatchesRegExDescription = violationMessage;
+        return self();
+    }
+
+    public P describedAs(final String violationMessage) {
+        describedAsConsumer.accept(violationMessage);
+        describedAsConsumer = null;
+        return self();
+    }
+
+    /// predefined values, similar to fixed values in a combobox
     public P provided(final String... provided) {
         this.provided = provided;
         return self();
@@ -78,16 +101,10 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
     @Override
     protected void validate(final List<String> result, final String propValue, final PropertiesProvider propProvider) {
         super.validate(result, propValue, propProvider);
-        if (minLength != null && propValue.length()<minLength) {
-            result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
-        }
-        if (maxLength != null && propValue.length()>maxLength) {
-            result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
-        }
-        if (matchesRegEx != null &&
-                stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) {
-            result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) + " but " + display(propValue) + " does not match" + (matchesRegEx.length>1?" any":""));
-        }
+        validateMinLength(result, propValue);
+        validateMaxLength(result, propValue);
+        validateMatchesRegEx(result, propValue);
+        validateNotMatchesRegEx(result, propValue);
     }
 
     @Override
@@ -99,4 +116,47 @@ public class StringProperty<P extends StringProperty<P>> extends ValidatableProp
     protected String simpleTypeName() {
         return "string";
     }
+
+    private void validateMinLength(final List<String> result, final String propValue) {
+        if (minLength != null && propValue.length()<minLength) {
+            result.add(propertyName + "' length is expected to be at min " + minLength + " but length of " + display(propValue) + " is " + propValue.length());
+        }
+    }
+
+    private void validateMaxLength(final List<String> result, final String propValue) {
+        if (maxLength != null && propValue.length()>maxLength) {
+            result.add(propertyName + "' length is expected to be at max " + maxLength + " but length of " + display(propValue) + " is " + propValue.length());
+        }
+    }
+
+    private void validateMatchesRegEx(final List<String> result, final String propValue) {
+        if (matchesRegEx != null &&
+                stream(matchesRegEx).map(p -> p.matcher(propValue)).noneMatch(Matcher::matches)) {
+            if (matchesRegExDescription != null) {
+                result.add(propertyName + "' = " + display(propValue) + " " + matchesRegExDescription);
+            } else if (matchesRegEx.length>1) {
+                result.add(propertyName + "' is expected to match any of " + Arrays.toString(matchesRegEx) +
+                        " but " + display(propValue) + " does not match any");
+            } else {
+                result.add(propertyName + "' is expected to match " + Arrays.toString(matchesRegEx) + " but " + display(
+                        propValue) +
+                        " does not match");
+            }
+        }
+    }
+
+    private void validateNotMatchesRegEx(final List<String> result, final String propValue) {
+        if (notMatchesRegEx != null &&
+                stream(notMatchesRegEx).map(p -> p.matcher(propValue)).anyMatch(Matcher::matches)) {
+            if (notMatchesRegExDescription != null) {
+                result.add(propertyName + "' = " + display(propValue) + " " + notMatchesRegExDescription);
+            } else if (notMatchesRegEx.length>1) {
+                result.add(propertyName + "' is expected not to match any of " + Arrays.toString(notMatchesRegEx) +
+                        " but " + display(propValue) + " does match at least one");
+            } else {
+                result.add(propertyName + "' is expected not to match " + Arrays.toString(notMatchesRegEx) +
+                        " but " + display(propValue) + " does match");
+            }
+        }
+    }
 }
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java
new file mode 100644
index 00000000..9fbdac45
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/booking/item/validators/HsDomainSetupBookingItemValidatorUnitTest.java
@@ -0,0 +1,155 @@
+package net.hostsharing.hsadminng.hs.booking.item.validators;
+
+import net.hostsharing.hsadminng.hs.booking.debitor.HsBookingDebitorEntity;
+import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
+import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealEntity;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import jakarta.persistence.EntityManager;
+import java.util.Map;
+
+import static java.util.Map.entry;
+import static net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType.DOMAIN_SETUP;
+import static org.apache.commons.lang3.StringUtils.right;
+import static org.assertj.core.api.Assertions.assertThat;
+
+class HsDomainSetupBookingItemValidatorUnitTest {
+
+    public static final String TOO_LONG_DOMAIN_NAME = "asdfghijklmnopqrstuvwxyz0123456789.".repeat(8) + "example.org";
+    final HsBookingDebitorEntity debitor = HsBookingDebitorEntity.builder()
+            .debitorNumber(12345)
+            .build();
+    final HsBookingProjectRealEntity project = HsBookingProjectRealEntity.builder()
+            .debitor(debitor)
+            .caption("Test-Project")
+            .build();
+    private EntityManager em;
+
+    @Test
+    void acceptsRegisterableDomain() {
+        // given
+        final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
+                .type(DOMAIN_SETUP)
+                .project(project)
+                .caption("Test-Domain")
+                .resources(Map.ofEntries(
+                        entry("domainName", "example.org")
+                ))
+                .build();
+
+        // when
+        final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
+
+        // then
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    void acceptsMaximumDomainNameLength() {
+        final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
+                .type(DOMAIN_SETUP)
+                .project(project)
+                .caption("Test-Domain")
+                .resources(Map.ofEntries(
+                        entry("domainName", right(TOO_LONG_DOMAIN_NAME, 253))
+                ))
+                .build();
+
+        // when
+        final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
+
+        // then
+        assertThat(result).isEmpty();
+    }
+
+    @Test
+    void rejectsTooLongTotalName() {
+        final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
+                .type(DOMAIN_SETUP)
+                .project(project)
+                .caption("Test-Domain")
+                .resources(Map.ofEntries(
+                        entry("domainName", right(TOO_LONG_DOMAIN_NAME, 254))
+                ))
+                .build();
+
+        // when
+        final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
+
+        // then
+        assertThat(result).contains("'D-12345:Test-Project:Test-Domain.resources.domainName' length is expected to be at max 253 but length of 'dfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.asdfghijklmnopqrstuvwxyz0123456789.example.org' is 254");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "de", "com", "net", "org", "actually-any-top-level-domain",
+            "co.uk", "org.uk", "gov.uk", "ac.uk", "sch.uk",
+            "com.au", "net.au", "org.au", "edu.au", "gov.au", "asn.au", "id.au",
+            "co.jp", "ne.jp", "or.jp", "ac.jp", "go.jp",
+            "com.cn", "net.cn", "org.cn", "gov.cn", "edu.cn", "ac.cn",
+            "com.br", "net.br", "org.br", "gov.br", "edu.br", "mil.br", "art.br",
+            "co.in", "net.in", "org.in", "gen.in", "firm.in", "ind.in",
+            "com.mx", "net.mx", "org.mx", "gob.mx", "edu.mx",
+            "gov.it", "edu.it",
+            "co.nz", "net.nz", "org.nz", "govt.nz", "ac.nz", "school.nz", "geek.nz", "kiwi.nz",
+            "co.kr", "ne.kr", "or.kr", "go.kr", "re.kr", "pe.kr"
+    })
+    void rejectRegistrarLevelDomain(final String secondLevelRegistrarDomain) {
+        // given
+        final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
+                .type(DOMAIN_SETUP)
+                .project(project)
+                .caption("Test-Domain")
+                .resources(Map.ofEntries(
+                        entry("domainName", secondLevelRegistrarDomain)
+                ))
+                .build();
+
+        // when
+        final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
+
+        // then
+        assertThat(result).contains(
+                "'D-12345:Test-Project:Test-Domain.resources.domainName' = '" +
+                secondLevelRegistrarDomain +
+                "' is a forbidden registrar-level domain name");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = {
+            "hostsharing.net", "hostsharing.org", "hostsharing.com", "hostsharing.coop", "hostsharing.de"
+    })
+    void rejectHostsharingDomain(final String secondLevelRegistrarDomain) {
+        // given
+        final var domainSetupBookingItemEntity = HsBookingItemRealEntity.builder()
+                .type(DOMAIN_SETUP)
+                .project(project)
+                .caption("Test-Domain")
+                .resources(Map.ofEntries(
+                        entry("domainName", secondLevelRegistrarDomain)
+                ))
+                .build();
+
+        // when
+        final var result = HsBookingItemEntityValidatorRegistry.doValidate(em, domainSetupBookingItemEntity);
+
+        // then
+        assertThat(result).containsExactly(
+                "'D-12345:Test-Project:Test-Domain.resources.domainName' = '" +
+                        secondLevelRegistrarDomain +
+                        "' is a forbidden Hostsharing domain name");
+    }
+
+    @Test
+    void containsAllValidations() {
+        // when
+        final var validator = HsBookingItemEntityValidatorRegistry.forType(DOMAIN_SETUP);
+
+        // then
+        assertThat(validator.properties()).map(Map::toString).containsExactlyInAnyOrder(
+                "{type=string, propertyName=domainName, matchesRegEx=[^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}], matchesRegExDescription=is not a (non-top-level) fully qualified domain name, notMatchesRegEx=[[^.]+, (co|org|gov|ac|sch)\\.uk, (com|net|org|edu|gov|asn|id)\\.au, (co|ne|or|ac|go)\\.jp, (com|net|org|gov|edu|ac)\\.cn, (com|net|org|gov|edu|mil|art)\\.br, (co|net|org|gen|firm|ind)\\.in, (com|net|org|gob|edu)\\.mx, (gov|edu)\\.it, (co|net|org|govt|ac|school|geek|kiwi)\\.nz, (co|ne|or|go|re|pe)\\.kr], notMatchesRegExDescription=is a forbidden registrar-level domain name, maxLength=253, required=true, writeOnce=true}",
+                "{type=string, propertyName=verificationCode, readOnly=true, computed=IN_INIT}");
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java
index 306337bb..81f3192e 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/HsHostingAssetControllerAcceptanceTest.java
@@ -9,10 +9,12 @@ import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
 import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealRepository;
 import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
 import net.hostsharing.hsadminng.hs.booking.project.HsBookingProjectRealRepository;
+import net.hostsharing.hsadminng.hs.hosting.asset.validators.Dns;
 import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealEntity;
 import net.hostsharing.hsadminng.hs.office.contact.HsOfficeContactRealRepository;
 import net.hostsharing.hsadminng.rbac.test.ContextBasedTestWithCleanup;
 import net.hostsharing.hsadminng.rbac.test.JpaAttempt;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.ClassOrderer;
 import org.junit.jupiter.api.Nested;
 import org.junit.jupiter.api.Order;
@@ -64,6 +66,11 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
     @Autowired
     JpaAttempt jpaAttempt;
 
+    @AfterEach
+    void cleanup() {
+        Dns.resetFakeResults();
+    }
+
     @Nested
     @Order(2)
     class ListAssets {
@@ -249,6 +256,7 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
         void globalAdmin_canAddTopLevelAsset() {
 
             context.define("superuser-alex@hostsharing.net");
+            Dns.fakeResultForDomain("example.com", new Dns.Result(Dns.Status.NAME_NOT_FOUND, null, null));
             final var givenProject = realProjectRepo.findByCaption("D-1000111 default project").stream()
                     .findAny().orElseThrow();
             final var bookingItem = givenSomeTemporaryBookingItem(() ->
@@ -256,6 +264,8 @@ class HsHostingAssetControllerAcceptanceTest extends ContextBasedTestWithCleanup
                        .project(givenProject)
                        .type(HsBookingItemType.DOMAIN_SETUP)
                        .caption("some temp domain setup booking item")
+                       .resources(Map.ofEntries(
+                               entry("domainName", "example.com")))
                        .build()
             );
 
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java
new file mode 100644
index 00000000..7a60d16c
--- /dev/null
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/DnsUnitTest.java
@@ -0,0 +1,29 @@
+package net.hostsharing.hsadminng.hs.hosting.asset.validators;
+
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DnsUnitTest {
+
+    @Test
+    void isRegistrarLevelDomain() {
+        assertThat(Dns.isRegistrarLevelDomain("de")).isTrue();
+        assertThat(Dns.isRegistrarLevelDomain("example.de")).isFalse();
+
+        assertThat(Dns.isRegistrarLevelDomain("co.uk")).isTrue();
+        assertThat(Dns.isRegistrarLevelDomain("example.co.uk")).isFalse();
+        assertThat(Dns.isRegistrarLevelDomain("co.uk.com")).isFalse();
+    }
+
+    @Test
+    void isRegistrableDomain() {
+        assertThat(Dns.isRegistrableDomain("de")).isFalse();
+        assertThat(Dns.isRegistrableDomain("example.de")).isTrue();
+        assertThat(Dns.isRegistrableDomain("sub.example.de")).isFalse();
+
+        assertThat(Dns.isRegistrableDomain("co.uk")).isFalse();
+        assertThat(Dns.isRegistrableDomain("example.co.uk")).isTrue();
+        assertThat(Dns.isRegistrableDomain("sub.example.co.uk")).isFalse();
+    }
+}
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java
index 4705a99e..91fecdd5 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainHttpSetupHostingAssetValidatorUnitTest.java
@@ -156,9 +156,9 @@ class HsDomainHttpSetupHostingAssetValidatorUnitTest {
         // then
         assertThat(result).containsExactlyInAnyOrder(
                 "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.htdocsfallback' is expected to be of type Boolean, but is of type String",
-                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.fcgi-php-bin' is expected to match any of [^/.*] but 'false' does not match",
-                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match any of [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '' does not match",
-                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match any of [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '@' does not match",
-                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match any of [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but 'example.com' does not match");
+                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.fcgi-php-bin' is expected to match [^/.*] but 'false' does not match",
+                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '' does not match",
+                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but '@' does not match",
+                "'DOMAIN_HTTP_SETUP:example.org|HTTP.config.subdomains' is expected to match [(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))] but 'example.com' does not match");
     }
 }
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java
index 6f451556..8b96ea76 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsDomainSetupHostingAssetValidatorUnitTest.java
@@ -2,14 +2,26 @@ package net.hostsharing.hsadminng.hs.hosting.asset.validators;
 
 import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemRealEntity;
 import net.hostsharing.hsadminng.hs.booking.item.HsBookingItemType;
+import net.hostsharing.hsadminng.hs.booking.item.validators.HsBookingItemEntityValidatorRegistry;
 import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRbacEntity;
 import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetRealEntity;
+import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.Test;
 import org.junit.jupiter.params.ParameterizedTest;
 import org.junit.jupiter.params.provider.EnumSource;
+import org.junit.jupiter.params.provider.ValueSource;
 
+import javax.naming.InvalidNameException;
+import javax.naming.NameNotFoundException;
+import javax.naming.NamingException;
+import javax.naming.ServiceUnavailableException;
+import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
+import java.util.function.Function;
 
+import static java.util.Map.entry;
+import static java.util.Map.ofEntries;
 import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.CLOUD_SERVER;
 import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_SETUP;
 import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.MANAGED_SERVER;
@@ -17,62 +29,90 @@ import static org.assertj.core.api.Assertions.assertThat;
 
 class HsDomainSetupHostingAssetValidatorUnitTest {
 
-    static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder() {
+    public static final Dns.Result DOMAIN_NOT_REGISTERED = Dns.Result.fromException(new NameNotFoundException(
+            "domain not registered"));
+
+    static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder(
+            final String domainName,
+            final Function<HsBookingItemRealEntity.HsBookingItemRealEntityBuilder<?, ?>, HsBookingItemRealEntity> buildBookingItem) {
+        final HsBookingItemRealEntity bookingItem = buildBookingItem.apply(
+                HsBookingItemRealEntity.builder()
+                        .type(HsBookingItemType.DOMAIN_SETUP)
+                        .resources(new HashMap<>(ofEntries(
+                                entry("domainName", domainName)
+                        ))));
+        HsBookingItemEntityValidatorRegistry.forType(HsBookingItemType.DOMAIN_SETUP).prepareProperties(null, bookingItem);
         return HsHostingAssetRbacEntity.builder()
                 .type(DOMAIN_SETUP)
-                .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.DOMAIN_SETUP).build())
-                .identifier("example.org");
+                .bookingItem(bookingItem)
+                .identifier(domainName);
     }
 
-    enum InvalidDomainNameIdentifier {
-        EMPTY(""),
-        TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.de"),
-        DASH_AT_BEGINNING("-example.com"),
-        DOT_AT_BEGINNING(".example.com"),
-        DOT_AT_END("example.com.");
+    static HsHostingAssetRbacEntity.HsHostingAssetRbacEntityBuilder<?, ?> validEntityBuilder(final String domainName) {
+        return validEntityBuilder(domainName, HsBookingItemRealEntity.HsBookingItemRealEntityBuilder::build);
+    }
+
+    @AfterEach
+    void cleanup() {
+        Dns.resetFakeResults();
+    }
+
+    //=====================================================================================================================
+
+    enum InvalidSubDomainNameIdentifierForExampleOrg {
+        IDENTICAL("example.org"),
+        TOO_LONG("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz0123456890123456789.example.org"),
+        DASH_AT_BEGINNING("-sub.example.org"),
+        DOT(".example.org"),
+        DOT_AT_BEGINNING(".sub.example.org"),
+        DOUBLE_DOT("sub..example.com.");
 
         final String domainName;
 
-        InvalidDomainNameIdentifier(final String domainName) {
+        InvalidSubDomainNameIdentifierForExampleOrg(final String domainName) {
             this.domainName = domainName;
         }
     }
 
     @ParameterizedTest
-    @EnumSource(InvalidDomainNameIdentifier.class)
-    void rejectsInvalidIdentifier(final InvalidDomainNameIdentifier testCase) {
+    @EnumSource(InvalidSubDomainNameIdentifierForExampleOrg.class)
+    void rejectsInvalidIdentifier(final InvalidSubDomainNameIdentifierForExampleOrg testCase) {
         // given
-        final var givenEntity = validEntityBuilder().identifier(testCase.domainName).build();
+        final var givenEntity = validEntityBuilder(testCase.domainName)
+                .bookingItem(null)
+                .parentAsset(HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier("example.org").build())
+                .build();
+        // fakeValidDnsVerification(givenEntity);
         final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
 
         // when
         final var result = validator.validateEntity(givenEntity);
 
         // then
-        assertThat(result).containsExactly(
-                "'identifier' expected to match '^((?!-)[A-Za-z0-9-]{1,63}(?<!-)\\.)+[A-Za-z]{2,12}', but is '"+testCase.domainName+"'"
+        assertThat(result).contains(
+                "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))\\.example\\.org', but is '" + testCase.domainName + "'"
         );
     }
 
-
-    enum ValidDomainNameIdentifier {
-        SIMPLE("exampe.org"),
-        MAX_LENGTH("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz01234568901.de"),
-        WITH_DASH("example-test.com"),
-        SUBDOMAIN("test.example.com");
+    enum ValidSubDomainNameIdentifier {
+        SIMPLE("sub.example.org"),
+        MAX_LENGTH("abcdefghijklmnopqrstuvwxyzabcdefghijklmnopqrstuvwxyz01234568901.example.org"),
+        MIN_LENGTH("x.example.org"),
+        WITH_DASH("example-test.example.org");
 
         final String domainName;
 
-        ValidDomainNameIdentifier(final String domainName) {
+        ValidSubDomainNameIdentifier(final String domainName) {
             this.domainName = domainName;
         }
     }
 
     @ParameterizedTest
-    @EnumSource(ValidDomainNameIdentifier.class)
-    void acceptsValidIdentifier(final ValidDomainNameIdentifier testCase) {
+    @EnumSource(ValidSubDomainNameIdentifier.class)
+    void acceptsValidIdentifier(final ValidSubDomainNameIdentifier testCase) {
         // given
-        final var givenEntity = validEntityBuilder().identifier(testCase.domainName).build();
+        final var givenEntity = validEntityBuilder(testCase.domainName).identifier(testCase.domainName).build();
+        fakeValidDnsVerification(givenEntity);
         final var validator = HostingAssetEntityValidatorRegistry.forType(givenEntity.getType());
 
         // when
@@ -82,6 +122,13 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
         assertThat(result).isEmpty();
     }
 
+    private static void fakeValidDnsVerification(final HsHostingAssetRbacEntity givenEntity) {
+        final var expectedHash = givenEntity.getBookingItem().getDirectValue("verificationCode", String.class);
+        Dns.fakeResultForDomain(
+                givenEntity.getIdentifier(),
+                Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash));
+    }
+
     @Test
     void containsNoProperties() {
         // when
@@ -94,10 +141,48 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
     @Test
     void validatesReferencedEntities() {
         // given
-        final var domainSetupHostingAssetEntity = validEntityBuilder()
+        final var domainSetupHostingAssetEntity = validEntityBuilder("example.org",
+                    bib -> bib.type(HsBookingItemType.CLOUD_SERVER).build())
                 .parentAsset(HsHostingAssetRealEntity.builder().type(CLOUD_SERVER).build())
                 .assignedToAsset(HsHostingAssetRealEntity.builder().type(MANAGED_SERVER).build())
-                .bookingItem(HsBookingItemRealEntity.builder().type(HsBookingItemType.CLOUD_SERVER).build())
+                .build();
+        final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType());
+
+        // when
+        final var result = validator.validateEntity(domainSetupHostingAssetEntity);
+
+        // then
+        assertThat(result).contains(
+                "'DOMAIN_SETUP:example.org.bookingItem' or parentItem must be null but is of type CLOUD_SERVER",
+                "'DOMAIN_SETUP:example.org.parentAsset' must be null or of type DOMAIN_SETUP but is of type CLOUD_SERVER",
+                "'DOMAIN_SETUP:example.org.assignedToAsset' must be null but is of type MANAGED_SERVER");
+    }
+
+    @Test
+    void rejectsDomainNameNotMatchingBookingItemDomainName() {
+        // given
+        final var domainSetupHostingAssetEntity = validEntityBuilder("not-matching-booking-item-domain-name.org",
+                    bib -> bib.resources(new HashMap<>(ofEntries(
+                            entry("domainName", "example.org")
+                    ))).build()
+                ).build();
+        final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType());
+
+        // when
+        final var result = validator.validateEntity(domainSetupHostingAssetEntity);
+
+        // then
+        assertThat(result).containsExactlyInAnyOrder(
+                "'identifier' expected to match 'example.org', but is 'not-matching-booking-item-domain-name.org'");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "not-matching-booking-item-domain-name.org", "indirect.subdomain.example.org" })
+    void rejectsDomainNameWhichIsNotADirectSubdomainOfParentAsset(final String newDomainName) {
+        // given
+        final var domainSetupHostingAssetEntity = validEntityBuilder(newDomainName)
+                .bookingItem(null)
+                .parentAsset(createValidParentDomainSetupAsset("example.org"))
                 .build();
         final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType());
 
@@ -106,21 +191,248 @@ class HsDomainSetupHostingAssetValidatorUnitTest {
 
         // then
         assertThat(result).containsExactlyInAnyOrder(
-                "'DOMAIN_SETUP:example.org.bookingItem' or parentItem must be null but is of type CLOUD_SERVER",
-                "'DOMAIN_SETUP:example.org.parentAsset' must be null or of type DOMAIN_SETUP but is of type CLOUD_SERVER",
-                "'DOMAIN_SETUP:example.org.assignedToAsset' must be null but is of type MANAGED_SERVER");
+                "'identifier' expected to match '(\\*|(?!-)[A-Za-z0-9-]{1,63}(?<!-))\\.example\\.org', " +
+                        "but is '" + newDomainName + "'");
     }
 
     @Test
-    void expectsEitherParentAssetOrBookingItem() {
+    void rejectsIfNeitherBookingItemNorParentAssetAreSet() {
+
         // given
-        final var domainSetupHostingAssetEntity = validEntityBuilder().build();
+        final var domainSetupHostingAssetEntity = validEntityBuilder("example.org")
+                .bookingItem(null)
+                .parentAsset(null)
+                .build();
+        Dns.fakeResultForDomain(
+                domainSetupHostingAssetEntity.getIdentifier(),
+                new Dns.Result(Dns.Status.NAME_NOT_FOUND, null, null));
         final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType());
 
         // when
         final var result = validator.validateEntity(domainSetupHostingAssetEntity);
 
         // then
-        assertThat(result).isEmpty();
+        assertThat(result).containsExactly("'DOMAIN_SETUP:example.org.bookingItem' must be of type DOMAIN_SETUP but is null");
+    }
+
+    enum DnsLookupFailureTestCase {
+        SERVICE_UNAVAILABLE(
+                new ServiceUnavailableException("no Internet connection"),
+                "[DNS] lookup failed for domain name 'example.org': javax.naming.ServiceUnavailableException: no Internet connection"),
+        NAME_NOT_FOUND(
+                new NameNotFoundException("domain name not found"),
+                null), // no
+        INVALID_NAME(
+                new InvalidNameException("domain name too long or whatever"),
+                "[DNS] invalid domain name 'example.org'"),
+        UNKNOWN_FAILURE(
+                new NamingException("some other problem"),
+                "[DNS] lookup failed for domain name 'example.org': javax.naming.NamingException: some other problem");
+
+        public final NamingException givenException;
+        public final String expectedErrorMessage;
+
+        DnsLookupFailureTestCase(final NamingException givenException, final String expectedErrorMessage) {
+            this.givenException = givenException;
+            this.expectedErrorMessage = expectedErrorMessage;
+        }
+    }
+
+    @ParameterizedTest
+    @EnumSource(DnsLookupFailureTestCase.class)
+    void handlesDnsLookupFailures(final DnsLookupFailureTestCase testCase) {
+
+        // given
+        final var domainSetupHostingAssetEntity = validEntityBuilder("example.org").build();
+        Dns.fakeResultForDomain(
+                domainSetupHostingAssetEntity.getIdentifier(),
+                Dns.Result.fromException(testCase.givenException));
+        final var validator = HostingAssetEntityValidatorRegistry.forType(domainSetupHostingAssetEntity.getType());
+
+        // when
+        final var result = validator.validateEntity(domainSetupHostingAssetEntity);
+
+        // then
+        if (testCase.expectedErrorMessage != null) {
+            assertThat(result).containsExactly(testCase.expectedErrorMessage);
+        } else {
+            assertThat(result).isEmpty();
+        }
+    }
+
+    //=====================================================================================================================
+
+    @Test
+    void allowSetupOfAvailableRegistrableDomain() {
+        domainSetupFor("example.com").notRegistered()
+                .isAccepted();
+    }
+
+    @Test
+    void allowSetupOfAvailableRegistrable2ndLevelDomain() {
+        domainSetupFor("example.co.uk").notRegistered()
+                .isAccepted();
+    }
+
+    @Test
+    void rejectSetupOfRegisteredRegistrable2ndLevelDomainWithoutVerification() {
+        domainSetupFor("example.co.uk").registered()
+                .isRejectedWithCauseMissingVerificationIn("example.co.uk");
+    }
+
+    @Test
+    void allowSetupOfRegisteredRegistrable2ndLevelDomainWithVerification() {
+        domainSetupFor("example.co.uk").registeredWithVerification()
+                .isAccepted();
+    }
+
+    @Test
+    void rejectSetupOfExistingRegistrableDomainWithoutValidDnsVerification() {
+        domainSetupFor("example.com").registered()
+                .isRejectedWithCauseMissingVerificationIn("example.com");
+    }
+
+    @Test
+    void allowSetupOfExistingRegistrableDomainWithValidDnsVerification() {
+        domainSetupFor("example.org").registeredWithVerification()
+                .isAccepted();
+    }
+
+    @Test
+    void allowSetupOfUnregisteredSubdomainWithValidDnsVerificationInSuperDomain() {
+        domainSetupFor("sub.example.org").notRegistered().withVerificationIn("example.org")
+                .isAccepted();
+    }
+
+    @Test
+    void rejectSetupOfExistingRegistrableDomainWithInvalidDnsVerification() {
+        domainSetupFor("example.com").registeredWithInvalidVerification()
+                .isRejectedWithCauseMissingVerificationIn("example.com");
+    }
+
+    @Test
+    void acceptSetupOfRegisteredSubdomainWithInvalidDnsVerificationButValidDnsVerificationInSuperDomain() {
+        domainSetupFor("sub.example.com").registeredWithInvalidVerification().withVerificationIn("example.com")
+                .isAccepted();
+    }
+
+    @Test
+    void rejectSetupOfUnregisteredSubdomainWithoutParentAssetAndWithoutDnsVerificationInSuperDomain() {
+        domainSetupFor("sub.example.org").notRegistered()
+                .isRejectedWithCauseMissingVerificationIn("example.org");
+    }
+
+    @Test
+    void acceptSetupOfUnregisteredSubdomainWithParentAssetEvenWithoutDnsVerificationInSuperDomain() {
+        domainSetupWithParentAssetFor("sub.example.org").notRegistered()
+                .isAccepted();
+    }
+
+    @Test
+    void allowSetupOfExistingSubdomainWithValidDnsVerificationInSuperDomain() {
+        domainSetupFor("sub.example.org").registered()
+                .withVerificationIn("example.org")
+                .isAccepted();
+    }
+
+    @Test
+    void rejectSetupOfExistingSubdomainWithoutDnsVerification() {
+        domainSetupFor("sub.example.org").registered()
+                .isRejectedWithCauseMissingVerificationIn("sub.example.org");
+    }
+
+    //====================================================================================================================
+
+    private static HsHostingAssetRealEntity createValidParentDomainSetupAsset(final String parentDomainName) {
+        final var bookingItem = HsBookingItemRealEntity.builder()
+                .type(HsBookingItemType.DOMAIN_SETUP)
+                .resources(ofEntries(
+                        entry("domainName", parentDomainName)
+                ))
+                .build();
+        final var parentAsset = HsHostingAssetRealEntity.builder()
+                .type(DOMAIN_SETUP)
+                .bookingItem(bookingItem)
+                .identifier(parentDomainName).build();
+        return parentAsset;
+    }
+
+    class DomainSetupBuilder {
+
+        private final HsHostingAssetRbacEntity domainAsset;
+        private final String expectedHash;
+
+        public DomainSetupBuilder(final String domainName) {
+            domainAsset = validEntityBuilder(domainName).build();
+            expectedHash = domainAsset.getBookingItem().getDirectValue("verificationCode", String.class);
+        }
+
+        public DomainSetupBuilder(final HsHostingAssetRealEntity parentAsset, final String domainName) {
+            domainAsset = validEntityBuilder(domainName)
+                    .bookingItem(null)
+                    .parentAsset(parentAsset)
+                    .build();
+            expectedHash = null;
+        }
+
+        DomainSetupBuilder notRegistered() {
+            Dns.fakeResultForDomain(domainAsset.getIdentifier(), DOMAIN_NOT_REGISTERED);
+            return this;
+        }
+
+        DomainSetupBuilder registered() {
+            Dns.fakeResultForDomain(
+                    domainAsset.getIdentifier(),
+                    Dns.Result.fromRecords());
+            return this;
+        }
+
+        DomainSetupBuilder registeredWithInvalidVerification() {
+            Dns.fakeResultForDomain(
+                    domainAsset.getIdentifier(),
+                    Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=SOME-DEFINITELY-WRONG-HASH"));
+            return this;
+        }
+
+        DomainSetupBuilder registeredWithVerification() {
+            withVerificationIn(domainAsset.getIdentifier());
+            return this;
+        }
+
+        DomainSetupBuilder withVerificationIn(final String domainName) {
+            assertThat(expectedHash).as("no expectedHash available").isNotNull();
+            Dns.fakeResultForDomain(
+                    domainName,
+                    Dns.Result.fromRecords("Hostsharing-domain-setup-verification-code=" + expectedHash));
+            return this;
+        }
+
+        void isRejectedWithCauseMissingVerificationIn(final String domainName) {
+            assertThat(expectedHash).as("no expectedHash available").isNotNull();
+            assertThat(validate()).containsAnyOf(
+                    "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash
+                            + "' found for domain name '" + domainName + "' (nor in its super-domain)",
+                    "[DNS] no TXT record 'Hostsharing-domain-setup-verification-code=" + expectedHash
+                            + "' found for domain name '" + domainName + "'");
+        }
+
+        void isAccepted() {
+            assertThat(validate()).isEmpty();
+        }
+
+        private List<String> validate() {
+            final var validator = HostingAssetEntityValidatorRegistry.forType(DOMAIN_SETUP);
+            return validator.validateEntity(domainAsset);
+        }
+    }
+
+    private DomainSetupBuilder domainSetupFor(final String domainName) {
+        return new DomainSetupBuilder(domainName);
+    }
+
+    private DomainSetupBuilder domainSetupWithParentAssetFor(final String domainName) {
+        return new DomainSetupBuilder(
+                HsHostingAssetRealEntity.builder().type(DOMAIN_SETUP).identifier(Dns.superDomain(domainName).orElseThrow()).build(),
+                domainName);
     }
 }
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java
index a06d3c5b..88adb55b 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsEMailAddressHostingAssetValidatorUnitTest.java
@@ -89,8 +89,8 @@ class HsEMailAddressHostingAssetValidatorUnitTest {
 
         // then
         assertThat(result).containsExactlyInAnyOrder(
-                "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match",
-                "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is expected to match any of [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match",
+                "'EMAIL_ADDRESS:old-local-part@example.org.config.local-part' is expected to match [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowed' does not match",
+                "'EMAIL_ADDRESS:old-local-part@example.org.config.sub-domain' is expected to match [^[a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+$] but 'no@allowedeither' does not match",
                 "'EMAIL_ADDRESS:old-local-part@example.org.config.target' is expected to match any of [^[a-z][a-z0-9]{2}[0-9]{2}(-[a-z0-9][a-z0-9\\.+_-]*)?$, ^([a-zA-Z0-9_!#$%&'*+/=?`{|}~^.-]+)?@[a-zA-Z0-9.-]+$, ^nobody$, ^/dev/null$] but 'garbage' does not match any");
     }
 
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java
index 04768707..95a950db 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/hosting/asset/validators/HsUnixUserHostingAssetValidatorUnitTest.java
@@ -141,7 +141,7 @@ class HsUnixUserHostingAssetValidatorUnitTest {
                 "'UNIX_USER:abc00-temp.config.HDD hard quota' is expected to be at most 0 but is 100",
                 "'UNIX_USER:abc00-temp.config.HDD soft quota' is expected to be at most 100 but is 200",
                 "'UNIX_USER:abc00-temp.config.homedir' is readonly but given as '/is/read-only'",
-                "'UNIX_USER:abc00-temp.config.totpKey' is expected to match any of [^0x([0-9A-Fa-f]{2})+$] but provided value does not match",
+                "'UNIX_USER:abc00-temp.config.totpKey' is expected to match [^0x([0-9A-Fa-f]{2})+$] but provided value does not match",
                 "'UNIX_USER:abc00-temp.config.password' length is expected to be at min 8 but length of provided value is 5",
                 "'UNIX_USER:abc00-temp.config.password' must contain at least one character of at least 3 of the following groups: upper case letters, lower case letters, digits, special characters"
         );
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java
index 9cb774d2..f00d57dd 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/BaseOfficeDataImport.java
@@ -69,7 +69,7 @@ public abstract class BaseOfficeDataImport extends CsvDataImport {
             512167, // 11139, partner without contractual contact
             512170, // 11142, partner without contractual contact
             511725, // 10764, partner without contractual contact
-            // 512171, // 11143, partner without partner contact -- exc
+            // 512171, // 11143, partner without partner contact -- exception
             -1
     );
 
diff --git a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java
index e96e7c6e..2f34ecee 100644
--- a/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java
+++ b/src/test/java/net/hostsharing/hsadminng/hs/migration/ImportHostingAssets.java
@@ -1352,7 +1352,7 @@ public class ImportHostingAssets extends BaseOfficeDataImport {
     }
 
     private void importDatabaseUsers(final String[] header, final List<String[]> records) {
-        HashGenerator.enableChouldBeHash(true);
+        HashGenerator.enableCouldBeHash(true);
         final var columns = new Columns(header);
         records.stream()
                 .map(this::trimAll)
@@ -1552,6 +1552,8 @@ public class ImportHostingAssets extends BaseOfficeDataImport {
                         .caption("BI " + domainSetup.getIdentifier())
                         .project((HsBookingProjectRealEntity) relatedProject)
                         //.validity(toPostgresDateRange(created, cancelled))
+                        .resources(Map.ofEntries(
+                                entry("domainName", domainSetup.getIdentifier())))
                         .build();
                 domainSetup.setBookingItem(bookingItem);
                 bookingItems.put(nextAvailableBookingItemId(), bookingItem);