HsDomainDnsSetupHostingAssetValidator.java

package net.hostsharing.hsadminng.hs.hosting.asset.validators;

import lombok.SneakyThrows;
import net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAsset;
import net.hostsharing.hsadminng.system.SystemProcess;

import java.util.List;
import java.util.regex.Pattern;

import static java.util.Arrays.stream;
import static java.util.Optional.ofNullable;
import static net.hostsharing.hsadminng.hs.hosting.asset.HsHostingAssetType.DOMAIN_DNS_SETUP;
import static net.hostsharing.hsadminng.hs.validation.ArrayProperty.arrayOf;
import static net.hostsharing.hsadminng.hs.validation.BooleanProperty.booleanProperty;
import static net.hostsharing.hsadminng.hs.validation.IntegerProperty.integerProperty;
import static net.hostsharing.hsadminng.hs.validation.StringProperty.stringProperty;

// TODO.legacy: make package private once we've migrated the legacy data
public class HsDomainDnsSetupHostingAssetValidator extends HostingAssetEntityValidator {

    // according to  RFC 1035 (section 5) and RFC 1034
    static final String RR_REGEX_NAME = "(\\*\\.)?([a-zA-Z0-9\\._-]+|@)[ \t]+";
    static final String RR_REGEX_TTL = "(([1-9][0-9]*[mMhHdDwW]?)+[ \t]+)?";
    static final String RR_REGEX_IN = "[iI][nN][ \t]+"; // record class IN for Internet
    static final String RR_RECORD_TYPE = "[a-zA-Z]+[ \t]+";
    static final String RR_RECORD_DATA = "(([^;]+)|(\".*\")|(\\(.*\\)))[ \t]*";
    static final String RR_COMMENT = "(;.*)?";

    static final String RR_REGEX_TTL_IN =
            RR_REGEX_NAME + RR_REGEX_TTL + RR_REGEX_IN + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;

    static final String RR_REGEX_IN_TTL =
            RR_REGEX_NAME  + RR_REGEX_IN + RR_REGEX_TTL + RR_RECORD_TYPE + RR_RECORD_DATA + RR_COMMENT;
    public static final String IDENTIFIER_SUFFIX = "|DNS";

    private static List<String> zoneFileErrors = null; // TODO.legacy: remove once legacy data is migrated

    HsDomainDnsSetupHostingAssetValidator() {
        super(
                DOMAIN_DNS_SETUP,
                AlarmContact.isOptional(),

                integerProperty("TTL").min(0).withDefault(21600),
                booleanProperty("auto-SOA").withDefault(true),
                booleanProperty("auto-NS-RR").withDefault(true),
                booleanProperty("auto-MX-RR").withDefault(true),
                booleanProperty("auto-A-RR").withDefault(true),
                booleanProperty("auto-AAAA-RR").withDefault(true),
                booleanProperty("auto-MAILSERVICES-RR").withDefault(true),
                booleanProperty("auto-AUTOCONFIG-RR").withDefault(true),
                booleanProperty("auto-AUTODISCOVER-RR").withDefault(true),
                booleanProperty("auto-DKIM-RR").withDefault(true),
                booleanProperty("auto-SPF-RR").withDefault(true),
                booleanProperty("auto-WILDCARD-MX-RR").withDefault(true),
                booleanProperty("auto-WILDCARD-A-RR").withDefault(true),
                booleanProperty("auto-WILDCARD-AAAA-RR").withDefault(true),
                booleanProperty("auto-WILDCARD-SPF-RR").withDefault(true),
                arrayOf(
                        stringProperty("user-RR").matchesRegEx(RR_REGEX_TTL_IN, RR_REGEX_IN_TTL).required()
                ).optional());
    }

    @Override
    protected Pattern identifierPattern(final HsHostingAsset assetEntity) {
        return Pattern.compile("^" + Pattern.quote(assetEntity.getParentAsset().getIdentifier() + IDENTIFIER_SUFFIX) + "$");
    }

    @Override
    public void preprocessEntity(final HsHostingAsset entity) {
        super.preprocessEntity(entity);
        if (entity.getIdentifier() == null) {
            ofNullable(entity.getParentAsset()).ifPresent(pa -> entity.setIdentifier(pa.getIdentifier() + IDENTIFIER_SUFFIX));
        }
    }

    @Override
    @SneakyThrows
    public List<String> validateContext(final HsHostingAsset assetEntity) {
        final var result = super.validateContext(assetEntity);

        // TODO.spec: define which checks should get raised to error level
        final var namedCheckZone = new SystemProcess("named-checkzone", fqdn(assetEntity));
        final var zonefileString = toZonefileString(assetEntity);
        final var zoneFileErrorResult = zoneFileErrors != null ? zoneFileErrors : result;
        if (namedCheckZone.execute(zonefileString) != 0) {
            // yes, named-checkzone writes error messages to stdout, not stderr
            stream(namedCheckZone.getStdOut().split("\n"))
                    .map(line -> line.replaceAll(" stream-0x[0-9a-f]+:", "line "))
                    .map(line -> "[" + assetEntity.getIdentifier() + "] " + line)
                    .forEach(zoneFileErrorResult::add);
            if (!namedCheckZone.getStdErr().isEmpty()) {
                result.add("unexpected stderr output for " + namedCheckZone.getCommand() + ": " + namedCheckZone.getStdErr());
            }
        }
        return result;
    }

    String toZonefileString(final HsHostingAsset assetEntity) {
        // TODO.spec: we need to expand the templates (auto-...) in the same way as in Saltstack, with proper IP-numbers etc.
        // TODO.impl: auto-AUTOCONFIG-RR auto-AUTODISCOVER-RR missing
        return """
                $TTL {ttl}

                {auto-SOA}
                {auto-NS-RR}
                {auto-MX-RR}
                {auto-A-RR}
                {auto-AAAA-RR}
                {auto-DKIM-RR}
                {auto-SPF-RR}
                
                {auto-WILDCARD-MX-RR}
                {auto-WILDCARD-A-RR}
                {auto-WILDCARD-AAAA-RR}
                {auto-WILDCARD-SPF-RR}
                
                {userRRs}
                """
                .replace("{ttl}", assetEntity.getDirectValue("TTL", Integer.class, 43200).toString())
                .replace("{auto-SOA}", assetEntity.getDirectValue("auto-SOA", Boolean.class, false).equals(true)
                        ? """
                            {domain}.   IN     SOA h00.hostsharing.net. hostmaster.hostsharing.net. (
                                                            1303649373      ; serial secs since Jan 1 1970
                                                                    6H      ; refresh (>=10000)
                                                                    1H      ; retry (>=1800)
                                                                    1W      ; expire
                                                                    1H      ; minimum
                                                            )
                            """
                        : "; no auto-SOA"
                )
                .replace("{auto-NS-RR}", assetEntity.getDirectValue("auto-NS-RR", Boolean.class, true)
                        ? """
                            {domain}.      IN      NS      dns1.hostsharing.net.
                            {domain}.      IN      NS      dns2.hostsharing.net.
                            {domain}.      IN      NS      dns3.hostsharing.net.
                            """
                        : "; no auto-NS-RR")
                .replace("{auto-MX-RR}", assetEntity.getDirectValue("auto-MX-RR", Boolean.class, true)
                        ?   """
                            {domain}.      IN      MX      30 mailin1.hostsharing.net.
                            {domain}.      IN      MX      30 mailin2.hostsharing.net.
                            {domain}.      IN      MX      30 mailin3.hostsharing.net.
                            """
                        : "; no auto-MX-RR")
                .replace("{auto-A-RR}",  assetEntity.getDirectValue("auto-A-RR", Boolean.class, true)
                        ? "{domain}.      IN  A       83.223.95.160" // arbitrary IP-number
                        : "; no auto-A-RR")
                .replace("{auto-AAAA-RR}",  assetEntity.getDirectValue("auto-AAA-RR", Boolean.class, true)
                        ? "{domain}.      IN  AAAA    2a01:37:1000::53df:5fa0:0" // arbitrary IP-number
                        : "; no auto-AAAA-RR")
                .replace("{auto-DKIM-RR}", assetEntity.getDirectValue("auto-DKIM-RR", Boolean.class, true)
                        ? "default._domainkey 21600 IN TXT \"v=DKIM1; h=sha256; k=rsa; s=email; p=MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCmdM9d15bqe94zbHVcKKpUF875XoCWHKRap/sG3NJZ9xZ/BjfGXmqoEYeFNpX3CB7pOXhH5naq4N+6gTjArTviAiVThHXyebhrxaf1dVS4IUC6raTEyQrWPZUf7ZxXmcCYvOdV4jIQ8GRfxwxqibIJcmMiufXTLIgRUif5uaTgFwIDAQAB\""
                        : "; no auto-DKIM-RR")
                .replace("{auto-SPF-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true)
                        ? "{domain}.      IN  TXT      \"v=spf1 include:spf.hostsharing.net ?all\""
                        : "; no auto-SPF-RR")
                .replace("{auto-WILDCARD-MX-RR}", assetEntity.getDirectValue("auto-SPF-RR", Boolean.class, true)
                        ? """
                            *.{domain}.      IN      MX      30 mailin1.hostsharing.net.
                            *.{domain}.      IN      MX      30 mailin1.hostsharing.net.
                            *.{domain}.      IN      MX      30 mailin1.hostsharing.net.
                            """
                        : "; no auto-WILDCARD-MX-RR")
                .replace("{auto-WILDCARD-A-RR}", assetEntity.getDirectValue("auto-WILDCARD-A-RR", Boolean.class, true)
                        ? "*.{domain}.      IN  A       83.223.95.160" // arbitrary IP-number
                        : "; no auto-WILDCARD-A-RR")
                .replace("{auto-WILDCARD-AAAA-RR}", assetEntity.getDirectValue("auto-WILDCARD-AAAA-RR", Boolean.class, true)
                        ? "*.{domain}.      IN  AAAA    2a01:37:1000::53df:5fa0:0" // arbitrary IP-number
                        : "; no auto-WILDCARD-AAAA-RR")
                .replace("{auto-WILDCARD-SPF-RR}", assetEntity.getDirectValue("auto-WILDCARD-SPF-RR", Boolean.class, true)
                        ? "*.{domain}.      IN  TXT      \"v=spf1 include:spf.hostsharing.net ?all\""
                        : "; no auto-WILDCARD-SPF-RR")
                .replace("{domain}", fqdn(assetEntity))
                .replace("{userRRs}", getPropertyValues(assetEntity, "user-RR"));
    }

    private String fqdn(final HsHostingAsset assetEntity) {
        return assetEntity.getIdentifier().substring(0, assetEntity.getIdentifier().length() - IDENTIFIER_SUFFIX.length());
    }

    public static void addZonefileErrorsTo(final List<String> zoneFileErrors) {
        HsDomainDnsSetupHostingAssetValidator.zoneFileErrors = zoneFileErrors;
    }
}