Skip to content
Permalink
Browse files
Enable "split DNS" configurations for an interface
By adding a tilde prefix to a domain name entry in the DNS= line, the
domain is interpreted as a "matching domain" for DNS routing instead of
a "search domain."  This corresponds to setting a non-empty
NEDNSSettings.matchDomains property for the network tunnel.  Using tilde
as a prefix is borrowed from systemd-resolved's equivalent usage.

If one or more match domains are specified, then the specified DNS
resolvers are only used for those matching domains instead of acting as
the first resolver before the system's primary DNS resolvers.

Signed-off-by: Stephen Larew <stephen@slarew.net>
  • Loading branch information
slarew authored and Stephen Larew committed Jun 27, 2021
1 parent 13b7204 commit 6ebc356
Show file tree
Hide file tree
Showing 6 changed files with 29 additions and 4 deletions.
@@ -136,6 +136,7 @@ extension TunnelConfiguration {
if !interface.dns.isEmpty || !interface.dnsSearch.isEmpty {
var dnsLine = interface.dns.map { $0.stringRepresentation }
dnsLine.append(contentsOf: interface.dnsSearch)
dnsLine.append(contentsOf: interface.dnsMatchDomains.map { "~" + $0 })
let dnsString = dnsLine.joined(separator: ", ")
output.append("DNS = \(dnsString)\n")
}
@@ -191,15 +192,19 @@ extension TunnelConfiguration {
if let dnsString = attributes["dns"] {
var dnsServers = [DNSServer]()
var dnsSearch = [String]()
var dnsMatchDomains = [String]()
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
if let dnsServer = DNSServer(from: dnsServerString) {
dnsServers.append(dnsServer)
} else if dnsServerString.first == "~" && !dnsServerString.dropFirst().isEmpty {
dnsMatchDomains.append(String(dnsServerString.dropFirst()))
} else {
dnsSearch.append(dnsServerString)
}
}
interface.dns = dnsServers
interface.dnsSearch = dnsSearch
interface.dnsMatchDomains = dnsMatchDomains
}
if let mtuString = attributes["mtu"] {
guard let mtu = UInt16(mtuString) else {
@@ -75,6 +75,7 @@ extension TunnelConfiguration {
interfaceConfiguration?.addresses = base?.interface.addresses ?? []
interfaceConfiguration?.dns = base?.interface.dns ?? []
interfaceConfiguration?.dnsSearch = base?.interface.dnsSearch ?? []
interfaceConfiguration?.dnsMatchDomains = base?.interface.dnsMatchDomains ?? []
interfaceConfiguration?.mtu = base?.interface.mtu

if let interfaceConfiguration = interfaceConfiguration {
@@ -139,9 +139,10 @@ class TunnelViewModel {
if let mtu = config.mtu {
scratchpad[.mtu] = String(mtu)
}
if !config.dns.isEmpty || !config.dnsSearch.isEmpty {
if !config.dns.isEmpty || !config.dnsSearch.isEmpty || !config.dnsMatchDomains.isEmpty {
var dns = config.dns.map { $0.stringRepresentation }
dns.append(contentsOf: config.dnsSearch)
dns.append(contentsOf: config.dnsMatchDomains.map { "~" + $0 })
scratchpad[.dns] = dns.joined(separator: ", ")
}
return scratchpad
@@ -197,15 +198,19 @@ class TunnelViewModel {
if let dnsString = scratchpad[.dns] {
var dnsServers = [DNSServer]()
var dnsSearch = [String]()
var dnsMatchDomains = [String]()
for dnsServerString in dnsString.splitToArray(trimmingCharacters: .whitespacesAndNewlines) {
if let dnsServer = DNSServer(from: dnsServerString) {
dnsServers.append(dnsServer)
} else if dnsServerString.first == "~" && !dnsServerString.dropFirst().isEmpty {
dnsMatchDomains.append(String(dnsServerString.dropFirst()))
} else {
dnsSearch.append(dnsServerString)
}
}
config.dns = dnsServers
config.dnsSearch = dnsSearch
config.dnsMatchDomains = dnsMatchDomains
}

guard errorMessages.isEmpty else { return .error(errorMessages.first!) }
@@ -121,6 +121,13 @@ static bool is_valid_hostname(string_span_t s)
return num_digit != num_entity;
}

static bool is_valid_dns_match_hostname(string_span_t s)
{
if (!s.len || s.s[0] != '~')
return false;
return is_valid_hostname((string_span_t){ s.s + 1, s.len - 1});
}

static bool is_valid_ipv4(string_span_t s)
{
for (size_t j, i = 0, pos = 0; i < 4 && pos < s.len; ++i) {
@@ -448,7 +455,7 @@ static void highlight_multivalue_value(struct highlight_span_array *ret, const s
case DNS:
if (is_valid_ipv4(s) || is_valid_ipv6(s))
append_highlight_span(ret, parent.s, s, HighlightIP);
else if (is_valid_hostname(s))
else if (is_valid_hostname(s) || is_valid_dns_match_hostname(s))
append_highlight_span(ret, parent.s, s, HighlightHost);
else
append_highlight_span(ret, parent.s, s, HighlightError);
@@ -11,6 +11,7 @@ public struct InterfaceConfiguration {
public var mtu: UInt16?
public var dns = [DNSServer]()
public var dnsSearch = [String]()
public var dnsMatchDomains = [String]()

public init(privateKey: PrivateKey) {
self.privateKey = privateKey
@@ -27,6 +28,7 @@ extension InterfaceConfiguration: Equatable {
lhs.listenPort == rhs.listenPort &&
lhs.mtu == rhs.mtu &&
lhs.dns == rhs.dns &&
lhs.dnsSearch == rhs.dnsSearch
lhs.dnsSearch == rhs.dnsSearch &&
lhs.dnsMatchDomains == rhs.dnsMatchDomains
}
}
@@ -88,7 +88,12 @@ class PacketTunnelSettingsGenerator {
let dnsSettings = NEDNSSettings(servers: dnsServerStrings)
dnsSettings.searchDomains = tunnelConfiguration.interface.dnsSearch
if !tunnelConfiguration.interface.dns.isEmpty {
dnsSettings.matchDomains = [""] // All DNS queries must first go through the tunnel's DNS
if tunnelConfiguration.interface.dnsMatchDomains.isEmpty {
// All DNS queries must first go through the tunnel's DNS
dnsSettings.matchDomains = [""]
} else {
dnsSettings.matchDomains = tunnelConfiguration.interface.dnsMatchDomains
}
}
networkSettings.dnsSettings = dnsSettings
}

7 comments on commit 6ebc356

@mdriessen
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm also having trouble with my search domain not working when using a split tunnel. Is there any way I could test these changes? I don't have an Apple Developer account that can build this project. Is a build of this commit downloadable somewhere or has there been any progress for merging this into upstream?

@slarew
Copy link
Owner Author

@slarew slarew commented on 6ebc356 Oct 27, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I will see if I can get a build to you. I haven't formally proposed this patch to upstream yet. It's been on my todo list for too long now.

@slarew
Copy link
Owner Author

@slarew slarew commented on 6ebc356 Oct 28, 2021

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems I cannot build a version to publish for general distribution. Network Extensions packaged as app extensions cannot be distributed outside the app store due to Apple's limitations. If the network extension was packaged as a system extension, then I believe I could sign with my developer ID for general distribution. Alas, I don't have the time right now to patch the wireguard app to use the system extension feature. Moreover, that would bump the minimum system version to 10.15 which may be undesirable for upstream.

@mdriessen
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you for replying so quickly! I was also unable to build this project with the free developer account due to those extensions. The mailinglist WireGuard uses is also another hurdle to send in bug reports so I thought I'd reach out this way.

My only concern for this to be merged upstream is that it introduces a new configuration semantic; ~matchdomain. They will likely want to keep the configuration the same for all clients. I did some reading on this (can't test anything unfortunately) and I think the problem lies in this line like anothers have mentioned;

dnsSettings.matchDomains = [""] // All DNS queries must first go through the tunnel's DNS

This works fine if I'm not using Split DNS (AllowedIPs = 0.0.0.0/0) but doesn't work when I only route some internal IP's. That's probably why someone else used this solution;

dnsSettings.matchDomains = [""] + dnsSettings.searchDomains // All DNS queries must first go through the tunnel's DNS

The solution I'll try to suggest to upstream is using the tunnel's DNS only when all traffic is routed through the tunnel or as a matchDomain when only some traffic is routed. I think that behaviour makes more sense without changing semantics. As far as I understand, the matchDomains will also be used as searchDomain (see https://developer.apple.com/documentation/networkextension/nednssettings/1406735-matchdomainsnosearch).

let dnsSettings = NEDNSSettings(servers: dnsServerStrings)
if tunnelConfiguration.interface.addresses.contains("0.0.0.0/0") { // Not sure if this works in Swift
    dnsSettings.searchDomains = tunnelConfiguration.interface.dnsSearch
    if !tunnelConfiguration.interface.dns.isEmpty {
        dnsSettings.matchDomains = [""] // All DNS queries must first go through the tunnel's DNS
    }
} else if !tunnelConfiguration.interface.dns.isEmpty {
    dnsSettings.matchDomains = tunnelConfiguration.interface.dnsSearch // Use the tunnel's DNS only for the given domains
}

I'm not sure if this code works for both our use cases. Any suggestions before I try to post this on the mailinglist?

@mdriessen
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just noticed you posted the patch on the mailinglist. Thank you for trying to resolve this upstream! Much appreciated!

@mdriessen
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just to let you know, I posted a message to the mailinglist but it is still awaiting approval by a moderator. Those mailing lists are really a pain to use!


Hello Andrew,

I just want to chime in here and say that I think the current
implementation of search domains is simply not working the way it
should on the MacOS client.

My use case is pretty common, an internal DNS server that has entries
for internal servers. I defined a search domain in the WireGuard
configuration; DNS = 10.13.13.1 mydomain.internal. The search domain
is for convenience, so I can just use the servername instead of
servername.mydomain.internal. Now this works fine when I route all the
traffic through the VPN (AllowedIPs = 0.0.0.0/0) but the search domain
is completely ignored when I only route the traffic I need to
(AllowedIPs = 10.13.13.0/24 192.168.0.0/24).

I don't think this is a configuration error on my side. The DNS
responds fine when using servername.mydomain.internal. This problem is
even mentioned in the "WireGuard macOS & iOS TODO List"
9. matchDomains=[“”] doesn’t do what the documentation says.
Specifically, DNS servers are not used if allowed IPs isn’t 0.0.0.0/0.

The description isn't 100% accurate (or outdated), the DNS server is
used but the search domain isn't being set on the primary resolver.
Some have solved this issue by adding the search domains to the list
of matchDomains; dnsSettings.matchDomains = [""] +
dnsSettings.searchDomains. But that way the DNS server specified in
WireGuard is still the primary resolver for all DNS queries.

Here is a link on how OpenVPN handles this and I think it's how it
should work when not using AllowedIPs 0.0.0.0/0.
https://openvpn.net/faq/how-does-ios-interpret-pushed-dns-servers-and-search-domains/
On a split-tunnel, where redirect-gateway is not pushed by the server,
and at least one pushed DNS server is present:

  • route all DNS requests through pushed DNS server(s) if no added
    search domains.
  • route DNS requests for added search domains only, if at least one
    added search domain.

Yours sincerely,
Matty

@classicmac
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I hope this gets added to the release version soon. Has anyone found a workaround in the interim?

Please sign in to comment.