fix normalizing dns record values for mx, srv and other type

This commit is contained in:
Manoj Kumar 2026-04-11 17:36:07 +05:30
parent 883dc32abf
commit 3ae9834325
No known key found for this signature in database
GPG Key ID: E952B7234D2C6F88
7 changed files with 335 additions and 119 deletions

View File

@ -50,10 +50,10 @@ public class CreateDnsRecordCmd extends BaseAsyncCmd {
description = "ID of the DNS zone")
private Long dnsZoneId;
@Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "Record name")
@Parameter(name = ApiConstants.NAME, type = CommandType.STRING, required = true, description = "DNS record name")
private String name;
@Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, required = true, description = "Record type (A, CNAME)")
@Parameter(name = ApiConstants.TYPE, type = CommandType.STRING, required = true, description = "DNS record type (e.g., A, AAAA, CNAME, MX, TXT, etc.)")
private String type;
@Parameter(name = ApiConstants.CONTENTS, type = CommandType.LIST, collectionType = CommandType.STRING, required = true,

View File

@ -27,11 +27,4 @@
<version>4.23.0.0-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>

View File

@ -18,7 +18,7 @@
package org.apache.cloudstack.dns;
import static org.apache.cloudstack.dns.DnsProviderUtil.appendPublicSuffixToZone;
import static org.apache.cloudstack.dns.DnsProviderUtil.normalizeDomain;
import static org.apache.cloudstack.dns.DnsProviderUtil.normalizeDomainForDb;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
@ -89,13 +89,13 @@ public class DnsProviderUtilTest {
if (Strings.isNotBlank(publicSuffix)) {
result = executeAppendSuffixTest(userZoneName, publicSuffix);
} else {
result = appendPublicSuffixToZone(normalizeDomain(userZoneName), publicSuffix);
result = appendPublicSuffixToZone(normalizeDomainForDb(userZoneName), publicSuffix);
}
assertEquals(expectedResult, result);
}
}
String executeAppendSuffixTest(String zoneName, String domainSuffix) {
return appendPublicSuffixToZone(normalizeDomain(zoneName), domainSuffix);
return appendPublicSuffixToZone(normalizeDomainForDb(zoneName), domainSuffix);
}
}

View File

@ -0,0 +1,197 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package org.apache.cloudstack.dns;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.A;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.AAAA;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.CNAME;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.MX;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.NS;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.PTR;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.SRV;
import static org.apache.cloudstack.dns.DnsRecord.RecordType.TXT;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.fail;
import java.util.Arrays;
import java.util.Collection;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.junit.runners.Parameterized;
@RunWith(Parameterized.class)
public class NormalizeDnsRecordValueTest {
private final String description;
private final String input;
private final DnsRecord.RecordType recordType;
private final String expected;
private final boolean expectException;
public NormalizeDnsRecordValueTest(String description, String input,
DnsRecord.RecordType recordType,
String expected, boolean expectException) {
this.description = description;
this.input = input;
this.recordType = recordType;
this.expected = expected;
this.expectException = expectException;
}
@Parameterized.Parameters(name = "{0}")
public static Collection<Object[]> data() {
return Arrays.asList(new Object[][] {
// ----------------------------------------------------------------
// Guard: blank/null value all record types should throw
// ----------------------------------------------------------------
{"null value, A record", null, A, null, true},
{"empty value, A record", "", A, null, true},
{"blank value, A record", " ", A, null, true},
{"null value, AAAA record", null, AAAA, null, true},
{"null value, CNAME record", null, CNAME, null, true},
{"null value, MX record", null, MX, null, true},
{"null value, TXT record", null, TXT, null, true},
{"null value, SRV record", null, SRV, null, true},
{"null value, NS record", null, NS, null, true},
{"null value, PTR record", null, PTR, null, true},
// ----------------------------------------------------------------
// A record
// ----------------------------------------------------------------
{"A: valid IPv4", "93.184.216.34", A, "93.184.216.34", false},
{"A: valid IPv4 with whitespace", " 93.184.216.34 ", A, "93.184.216.34", false},
{"A: loopback", "127.0.0.1", A, "127.0.0.1", false},
{"A: all-zeros", "0.0.0.0", A, "0.0.0.0", false},
{"A: broadcast", "255.255.255.255", A, "255.255.255.255", false},
{"A: private 10.x", "10.0.0.1", A, "10.0.0.1", false},
{"A: private 192.168.x", "192.168.1.1", A, "192.168.1.1", false},
{"A: IPv6 rejected", "2001:db8::1", A, null, true},
{"A: domain rejected", "example.com", A, null, true},
{"A: partial IP rejected", "192.168.1", A, null, true},
{"A: trailing dot rejected", "93.184.216.34.", A, null, true},
{"A: octet out of range rejected", "256.0.0.1", A, null, true},
// ----------------------------------------------------------------
// AAAA record
// ----------------------------------------------------------------
{"AAAA: full IPv6", "2001:0db8:0000:0000:0000:0000:0000:0001", AAAA,
"2001:0db8:0000:0000:0000:0000:0000:0001", false},
{"AAAA: compressed IPv6", "2001:db8::1", AAAA, "2001:db8::1", false},
{"AAAA: loopback", "::1", AAAA, "::1", false},
{"AAAA: all zeros", "::", AAAA, "::", false},
{"AAAA: whitespace", " 2001:db8::1 ", AAAA, "2001:db8::1", false},
{"AAAA: IPv4 rejected", "93.184.216.34", AAAA, null, true},
{"AAAA: domain rejected", "example.com", AAAA, null, true},
{"AAAA: invalid hex rejected", "2001:db8::xyz", AAAA, null, true},
// ----------------------------------------------------------------
// CNAME record
// ----------------------------------------------------------------
{"CNAME: basic", "target.example.com", CNAME, "target.example.com.", false},
{"CNAME: uppercase", "TARGET.EXAMPLE.COM", CNAME, "target.example.com.", false},
{"CNAME: trailing dot", "target.example.com.", CNAME, "target.example.com.", false},
{"CNAME: whitespace", " target.example.com ", CNAME, "target.example.com.", false},
{"CNAME: subdomain", "sub.target.example.com", CNAME, "sub.target.example.com.", false},
{"CNAME: IP rejected", "192.168.1.1", CNAME, null, true},
{"CNAME: invalid label", "-bad.example.com", CNAME, null, true},
// ----------------------------------------------------------------
// NS record
// ----------------------------------------------------------------
{"NS: basic", "ns1.example.com", NS, "ns1.example.com.", false},
{"NS: uppercase", "NS1.EXAMPLE.COM", NS, "ns1.example.com.", false},
{"NS: trailing dot", "ns1.example.com.", NS, "ns1.example.com.", false},
{"NS: subdomain", "ns1.sub.example.com", NS, "ns1.sub.example.com.", false},
{"NS: IP rejected", "8.8.8.8", NS, null, true},
{"NS: invalid label", "ns1-.example.com", NS, null, true},
// ----------------------------------------------------------------
// PTR record
// ----------------------------------------------------------------
{"PTR: basic", "host.example.com", PTR, "host.example.com.", false},
{"PTR: in-addr.arpa", "1.168.192.in-addr.arpa", PTR, "1.168.192.in-addr.arpa.", false},
{"PTR: uppercase", "HOST.EXAMPLE.COM", PTR, "host.example.com.", false},
{"PTR: trailing dot", "host.example.com.", PTR, "host.example.com.", false},
{"PTR: IP rejected", "192.168.1.1", PTR, null, true},
{"PTR: invalid label", "-host.example.com", PTR, null, true},
// ----------------------------------------------------------------
// MX record
// ----------------------------------------------------------------
{"MX: standard", "10 mail.example.com", MX, "10 mail.example.com.", false},
{"MX: zero priority", "0 mail.example.com", MX, "0 mail.example.com.", false},
{"MX: max priority", "65535 mail.example.com", MX, "65535 mail.example.com.", false},
{"MX: uppercase", "10 MAIL.EXAMPLE.COM", MX, "10 mail.example.com.", false},
{"MX: trailing dot", "10 mail.example.com.", MX, "10 mail.example.com.", false},
{"MX: extra whitespace", "10 mail.example.com", MX, "10 mail.example.com.", false},
{"MX: missing domain", "10", MX, null, true},
{"MX: priority out of range", "65536 mail.example.com", MX, null, true},
{"MX: non-numeric priority", "abc mail.example.com", MX, null, true},
{"MX: IP rejected", "10 192.168.1.1", MX, null, true},
// ----------------------------------------------------------------
// SRV record
// ----------------------------------------------------------------
{"SRV: standard", "10 20 443 target.example.com", SRV, "10 20 443 target.example.com.", false},
{"SRV: zeros", "0 0 1 target.example.com", SRV, "0 0 1 target.example.com.", false},
{"SRV: max values", "65535 65535 65535 target.example.com", SRV, "65535 65535 65535 target.example.com.", false},
{"SRV: uppercase", "10 20 443 TARGET.EXAMPLE.COM", SRV, "10 20 443 target.example.com.", false},
{"SRV: trailing dot", "10 20 443 target.example.com.", SRV, "10 20 443 target.example.com.", false},
{"SRV: missing target", "10 20 443", SRV, null, true},
{"SRV: port 0", "10 20 0 target.example.com", SRV, null, true},
{"SRV: priority out of range", "65536 20 443 target.example.com", SRV, null, true},
{"SRV: IP rejected", "10 20 443 192.168.1.1", SRV, null, true},
// ----------------------------------------------------------------
// TXT record
// ----------------------------------------------------------------
{"TXT: trim", " hello world ", TXT, "hello world", false},
{"TXT: already clean", "v=spf1 include:example.com ~all", TXT, "v=spf1 include:example.com ~all", false},
{"TXT: special chars", "v=DKIM1; k=rsa; p=MIGf", TXT, "v=DKIM1; k=rsa; p=MIGf", false},
{"TXT: unicode", "héllo wörld", TXT, "héllo wörld", false},
{"TXT: multiple spaces", "key=value with spaces", TXT, "key=value with spaces", false},
{"TXT: quoted", "\"quoted value\"", TXT, "\"quoted value\"", false},
{"TXT: blank", " ", TXT, null, true},
{"TXT: newline", "\n", TXT, null, true},
});
}
@Test
public void testNormalizeDnsRecordValue() {
if (expectException) {
try {
DnsProviderUtil.normalizeDnsRecordValue(input, recordType);
fail("Expected IllegalArgumentException for [" + description + "] input='" + input + "'");
} catch (IllegalArgumentException ignored) {}
} else {
String result = DnsProviderUtil.normalizeDnsRecordValue(input, recordType);
assertEquals(description, expected, result);
}
}
}

View File

@ -172,7 +172,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
}
if (StringUtils.isNotBlank(publicDomainSuffix)) {
publicDomainSuffix = DnsProviderUtil.normalizeDomain(publicDomainSuffix);
publicDomainSuffix = DnsProviderUtil.normalizeDomainForDb(publicDomainSuffix);
}
DnsProviderType type = cmd.getProvider();
@ -263,7 +263,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
}
if (cmd.getPublicDomainSuffix() != null) {
dnsServer.setPublicDomainSuffix(DnsProviderUtil.normalizeDomain(cmd.getPublicDomainSuffix()));
dnsServer.setPublicDomainSuffix(DnsProviderUtil.normalizeDomainForDb(cmd.getPublicDomainSuffix()));
}
if (cmd.getNameServers() != null) {
@ -552,7 +552,7 @@ public class DnsProviderManagerImpl extends ManagerBase implements DnsProviderMa
throw new InvalidParameterValueException("DNS zone name cannot be empty");
}
String dnsZoneName = DnsProviderUtil.normalizeDomain(cmd.getName());
String dnsZoneName = DnsProviderUtil.normalizeDomainForDb(cmd.getName());
DnsServerVO server = dnsServerDao.findById(cmd.getDnsServerId());
if (server == null) {
throw new InvalidParameterValueException(String.format("DNS server not found for the given ID: %s", cmd.getDnsServerId()));

View File

@ -17,9 +17,13 @@
package org.apache.cloudstack.dns;
import java.net.Inet4Address;
import java.net.Inet6Address;
import org.apache.commons.validator.routines.DomainValidator;
import com.cloud.utils.StringUtils;
import com.google.common.net.InetAddresses;
public class DnsProviderUtil {
static DomainValidator validator = DomainValidator.getInstance(true);
@ -28,9 +32,9 @@ public class DnsProviderUtil {
if (StringUtils.isBlank(suffixDomain)) {
return zoneName;
}
suffixDomain = DnsProviderUtil.normalizeDomain(suffixDomain);
suffixDomain = DnsProviderUtil.normalizeDomainForDb(suffixDomain);
// Already suffixed return as-is
if (zoneName.toLowerCase().endsWith("." + suffixDomain.toLowerCase())) {
if (zoneName.toLowerCase().endsWith("." + suffixDomain)) {
return zoneName;
}
@ -55,7 +59,8 @@ public class DnsProviderUtil {
return labels[labels.length - 1];
}
public static String normalizeDomain(String domain) {
// lowercase, no trailing dot (used for DB storage, comparisons)
public static String normalizeDomainForDb(String domain) {
if (StringUtils.isBlank(domain)) {
throw new IllegalArgumentException("Domain cannot be empty");
}
@ -71,32 +76,144 @@ public class DnsProviderUtil {
return normalized;
}
// DNS wire form: lowercase, validated, WITH trailing dot (used in record values)
public static String normalizeDnsRecordValue(String value, DnsRecord.RecordType recordType) {
if (StringUtils.isBlank(value)) {
throw new IllegalArgumentException("DNS record value cannot be empty");
}
String trimmedValue = value.trim();
switch (recordType) {
case A:
if (!(InetAddresses.forString(trimmedValue) instanceof Inet4Address)) {
throw new IllegalArgumentException(
String.format("Invalid IP address for %s record: %s", recordType, value));
}
return trimmedValue;
case AAAA:
// IP addresses: trim only
return value.trim();
if (!(InetAddresses.forString(trimmedValue) instanceof Inet6Address)) {
throw new IllegalArgumentException(
String.format("Invalid IP address for %s record: %s", recordType, value));
}
return trimmedValue;
case CNAME:
case NS:
case PTR:
return normalizeDomainForDnsRecord(trimmedValue);
case SRV:
// Domain names: normalize like zones
return normalizeDomain(value);
return normalizeSrvRecord(trimmedValue);
case MX:
// PowerDNS MX: contains priority + domain, only trim and lowercase
return value.trim().toLowerCase();
return normalizeMxRecord(trimmedValue);
case TXT:
// Free text: preserve exactly
return value;
return trimmedValue;
default:
throw new IllegalArgumentException("Unsupported DNS record type: " + recordType);
}
}
static String normalizeDomainForDnsRecord(String domain) {
if (StringUtils.isBlank(domain)) {
throw new IllegalArgumentException("Domain name cannot be empty");
}
String normalized = domain.trim().toLowerCase();
// Strip trailing dot first (normalize input)
if (normalized.endsWith(".")) {
normalized = normalized.substring(0, normalized.length() - 1);
}
// Reject IP addresses
if (InetAddresses.isInetAddress(normalized)) {
throw new IllegalArgumentException("Domain cannot be an IP address: " + normalized);
}
// Validate total length (max 253 chars, excluding trailing dot)
if (normalized.length() > 253) {
throw new IllegalArgumentException(
"Domain name exceeds maximum length: " + normalized);
}
// Validate labels
String[] labels = normalized.split("\\.", -1);
for (String label : labels) {
if (label.isEmpty()) {
throw new IllegalArgumentException(
"Domain contains empty label: " + normalized);
}
if (label.length() > 63) {
throw new IllegalArgumentException(
"Domain label too long: " + label);
}
if (!label.matches("[a-z0-9]([a-z0-9-]*[a-z0-9])?")) {
throw new IllegalArgumentException(
"Invalid domain label: " + label);
}
}
return normalized + ".";
}
private static String normalizeSrvRecord(String value) {
String trimmed = value.trim();
String[] parts = trimmed.split("\\s+", 4);
if (parts.length != 4) {
throw new IllegalArgumentException(
"Invalid SRV record value (expected '<priority> <weight> <port> <target>'): " + trimmed);
}
int priority;
int weight;
int port;
try {
priority = Integer.parseInt(parts[0]);
weight = Integer.parseInt(parts[1]);
port = Integer.parseInt(parts[2]);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"SRV priority/weight/port must be numeric: " + trimmed);
}
if (priority < 0 || priority > 65535) {
throw new IllegalArgumentException("SRV priority out of range (065535): " + parts[0]);
}
if (weight < 0 || weight > 65535) {
throw new IllegalArgumentException("SRV weight out of range (065535): " + parts[1]);
}
if (port < 1 || port > 65535) {
throw new IllegalArgumentException("SRV port out of range (165535): " + parts[2]);
}
String target = normalizeDomainForDnsRecord(parts[3]);
return priority + " " + weight + " " + port + " " + target;
}
private static String normalizeMxRecord(String value) {
String trimmed = value.trim();
String[] parts = trimmed.split("\\s+", 2);
if (parts.length != 2) {
throw new IllegalArgumentException(
"Invalid MX record value (expected '<priority> <mail-exchanger>'): " + trimmed);
}
int priority;
try {
priority = Integer.parseInt(parts[0]);
} catch (NumberFormatException e) {
throw new IllegalArgumentException(
"MX priority must be numeric: " + parts[0]);
}
if (priority < 0 || priority > 65535) {
throw new IllegalArgumentException(
"MX priority out of range (065535): " + parts[0]);
}
String mailExchanger = normalizeDomainForDnsRecord(parts[1]);
return priority + " " + mailExchanger;
}
}

View File

@ -1,91 +0,0 @@
// Licensed to the Apache Software Foundation (ASF) under one
// or more contributor license agreements. See the NOTICE file
// distributed with this work for additional information
// regarding copyright ownership. The ASF licenses this file
// to you under the Apache License, Version 2.0 (the
// "License"); you may not use this file except in compliance
// with the License. You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing,
// software distributed under the License is distributed on an
// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
// KIND, either express or implied. See the License for the
// specific language governing permissions and limitations
// under the License.
package org.apache.cloudstack.dns;
import static org.junit.Assert.assertEquals;
import org.junit.Test;
public class DnsProviderUtilTest {
@Test
public void testNormalizeDnsRecordValueA() {
String result = DnsProviderUtil.normalizeDnsRecordValue(" 1.2.3.4 ", DnsRecord.RecordType.A);
assertEquals("1.2.3.4", result);
}
@Test
public void testNormalizeDnsRecordValueAAAA() {
String result = DnsProviderUtil.normalizeDnsRecordValue(" 2001:db8::1 ", DnsRecord.RecordType.AAAA);
assertEquals("2001:db8::1", result);
}
@Test
public void testNormalizeDnsRecordValueCNAME() {
// Appends dot in the process? No, normalizeDomain trims, lowercases, removes trailing dot, and checks validity.
String result = DnsProviderUtil.normalizeDnsRecordValue(" Host.Example.Com. ", DnsRecord.RecordType.CNAME);
assertEquals("host.example.com", result);
}
@Test
public void testNormalizeDnsRecordValueNS() {
String result = DnsProviderUtil.normalizeDnsRecordValue("NS1.EXAMPLE.COM", DnsRecord.RecordType.NS);
assertEquals("ns1.example.com", result);
}
@Test
public void testNormalizeDnsRecordValuePTR() {
String result = DnsProviderUtil.normalizeDnsRecordValue("ptr.valid.zone.", DnsRecord.RecordType.PTR);
assertEquals("ptr.valid.zone", result);
}
@Test
public void testNormalizeDnsRecordValueSRV() {
String result = DnsProviderUtil.normalizeDnsRecordValue("srv.example.com", DnsRecord.RecordType.SRV);
assertEquals("srv.example.com", result);
}
@Test
public void testNormalizeDnsRecordValueMX() {
// MX records just get trimmed and lowercased
String result = DnsProviderUtil.normalizeDnsRecordValue(" 10 MAIL.EXAMPLE.COM ", DnsRecord.RecordType.MX);
assertEquals("10 mail.example.com", result);
}
@Test
public void testNormalizeDnsRecordValueTXT() {
// TXT records are preserved exactly
String result = DnsProviderUtil.normalizeDnsRecordValue(" Exact text value. ", DnsRecord.RecordType.TXT);
assertEquals(" Exact text value. ", result);
}
@Test(expected = IllegalArgumentException.class)
public void testNormalizeDnsRecordValueEmpty() {
DnsProviderUtil.normalizeDnsRecordValue(" ", DnsRecord.RecordType.A);
}
@Test(expected = IllegalArgumentException.class)
public void testNormalizeDnsRecordValueNull() {
DnsProviderUtil.normalizeDnsRecordValue(null, DnsRecord.RecordType.A);
}
@Test(expected = IllegalArgumentException.class)
public void testNormalizeDnsRecordValueInvalidDomain() {
DnsProviderUtil.normalizeDnsRecordValue("invalid!domain", DnsRecord.RecordType.CNAME);
}
}