From 398da7e2d3fec554db4bc7d658abf7c367cea9ab Mon Sep 17 00:00:00 2001
From: Artem Baskal <a.baskal@adguard.com>
Date: Thu, 8 Oct 2020 11:34:36 +0300
Subject: [PATCH] + client, home: 2110 Generate .mobileconfig

Close #2110

Squashed commit of the following:

commit 3a652a23b21b4eb16dd7b09f149099c93bf7a977
Merge: 5d0d6c5e 65acfb75
Author: Andrey Meshkov <am@adguard.com>
Date:   Wed Oct 7 21:01:54 2020 +0300

    Merge branch 'master' into feature/2110

commit 5d0d6c5e8704c80ae526d92966dfee0c469019bb
Author: Andrey Meshkov <am@adguard.com>
Date:   Wed Oct 7 00:28:25 2020 +0300

    * (home): minor refactoring

commit e1d10252f5b00c94edb9faa85eaefa3d33ac9cbf
Merge: f859ef14 fb7ca942
Author: Andrey Meshkov <am@adguard.com>
Date:   Wed Oct 7 00:18:46 2020 +0300

    Merge branch 'master' into feature/2110

commit f859ef144c54123d8ff262177148959f7b41a5a4
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Tue Oct 6 19:30:18 2020 +0300

    Update ServerURL, generate all uniqie uuid

commit 3ce7c573229f87579ff150f6519077ced9c5ba23
Merge: e80cf6de a7d2dd7b
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Oct 2 18:46:03 2020 +0300

    Merge branch 'master' into feature/2110

commit e80cf6ded1c20a4384cb94200134d67b29c0c948
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Oct 2 18:33:12 2020 +0300

    Describe .mobileconfig in openapi, allow unauthorized access for .mobileconfig

commit 9887d1839f8f7e4888fc23bb64cfc43a42b6f58b
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Oct 2 16:14:45 2020 +0300

    Change .mobileconfig generation

commit 5298dd706c107f5b02f4278a8773f6af387c36b1
Merge: cd4d1a74 128229ad
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Oct 2 12:01:16 2020 +0300

    Merge branch 'master' into feature/2110

commit cd4d1a748e2471890b31533e4c24272a3d01cbee
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Oct 1 23:10:14 2020 +0300

    Change dot and doh highlight in setup_dns_privacy_4 locale

commit 50e310ef3b988f2aad5accea92c6b34ecef28585
Merge: 92e0e28b 2f6f65a8
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Thu Oct 1 23:05:45 2020 +0300

    Merge branch 'master' into feature/2110

commit 92e0e28b757953efbbc211ae43b710b070308573
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Sep 28 16:44:25 2020 +0300

    Add ServerAddresses property

commit c8c4cf88abcb0a76c6024d41d3eafab691ff1e38
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Mon Sep 28 13:51:53 2020 +0300

    Fix .mobileconfig display on SetupGuide

commit 9e4fad3c0ed0bfb980ad1cb030272781c13ebaad
Author: ArtemBaskal <a.baskal@adguard.com>
Date:   Fri Sep 25 19:08:50 2020 +0300

    2110 + client, home: Generate .mobileconfig
---
 AGHTechDoc.md                     |  53 +++++++++++++
 client/src/__locales/en.json      |   5 +-
 client/src/components/ui/Guide.js | 124 +++++++++++++++++++-----------
 go.mod                            |   2 +
 go.sum                            |   4 +
 home/control.go                   |   5 +-
 home/dns.go                       |  69 +++++++++++------
 home/mobileconfig.go              |  92 ++++++++++++++++++++++
 openapi/openapi.yaml              |  23 ++++++
 9 files changed, 310 insertions(+), 67 deletions(-)
 create mode 100644 home/mobileconfig.go

diff --git a/AGHTechDoc.md b/AGHTechDoc.md
index 5e9fa0d5..5bae787a 100644
--- a/AGHTechDoc.md
+++ b/AGHTechDoc.md
@@ -830,6 +830,36 @@ Request:
 	"private_key_path":"..." // if set, private_key must be empty
 	}
 
+Response:
+
+	200 OK
+	
+### API: Validate TLS configuration
+
+Request:
+
+	POST /control/tls/validate
+
+    {
+    "enabled":true,
+    "port_https":443,
+    "port_dns_over_tls":853,
+    "port_dns_over_quic":784,
+    "allow_unencrypted_doh":false,
+    "certificate_chain":"...",
+    "private_key":"...",
+    "certificate_path":"...",
+    "private_key_path":"...",
+    "valid_cert":true,
+    "valid_chain":false,
+    "not_before":"2019-03-19T08:23:45Z",
+    "not_after":"2029-03-16T08:23:45Z",
+    "dns_names":null,
+    "valid_key":true,
+    "valid_pair":true
+    }
+
+
 Response:
 
 	200 OK
@@ -1948,6 +1978,29 @@ Check if host name is blocked by SB/PC service:
 		sha256(sub.host.com)[0..1] -> hashes[2],...
 		...
 
+## API: Get DNS over HTTPS .mobileconfig
+
+Request:
+
+	GET /apple/doh.mobileconfig
+
+Response:
+
+	200 OK
+	
+    DOH plist file
+
+## API: Get DNS over TLS .mobileconfig
+
+Request:
+
+	GET /apple/dot.mobileconfig
+
+Response:
+
+	200 OK
+
+    DOT plist file
 
 ## ipset
 
diff --git a/client/src/__locales/en.json b/client/src/__locales/en.json
index 678dd39f..8f2eb5c9 100644
--- a/client/src/__locales/en.json
+++ b/client/src/__locales/en.json
@@ -249,6 +249,8 @@
     "blocking_ipv6": "Blocking IPv6",
     "dns_over_https": "DNS-over-HTTPS",
     "dns_over_tls": "DNS-over-TLS",
+    "download_mobileconfig_doh": "Download .mobileconfig for DNS-over-HTTPS",
+    "download_mobileconfig_dot": "Download .mobileconfig for DNS-over-TLS",
     "plain_dns": "Plain DNS",
     "form_enter_rate_limit": "Enter rate limit",
     "rate_limit": "Rate limit",
@@ -415,7 +417,8 @@
     "dns_privacy": "DNS Privacy",
     "setup_dns_privacy_1": "<0>DNS-over-TLS:</0> Use <1>{{address}}</1> string.",
     "setup_dns_privacy_2": "<0>DNS-over-HTTPS:</0> Use <1>{{address}}</1> string.",
-    "setup_dns_privacy_3": "<0>Please note that encrypted DNS protocols are supported only on Android 9. So you need to install additional software for other operating systems.</0><0>Here's a list of software you can use.</0>",
+    "setup_dns_privacy_3": "<0>Here's a list of software you can use.</0>",
+    "setup_dns_privacy_4": "On an iOS 14 or MacOS Big Sur device you can download special '.mobileconfig' file that adds <highlight>DNS-over-HTTPS</highlight> or <highlight>DNS-over-TLS</highlight> servers to the DNS settings.",
     "setup_dns_privacy_android_1": "Android 9 supports DNS-over-TLS natively. To configure it, go to Settings → Network & internet → Advanced → Private DNS and enter your domain name there.",
     "setup_dns_privacy_android_2": "<0>AdGuard for Android</0> supports <1>DNS-over-HTTPS</1> and <1>DNS-over-TLS</1>.",
     "setup_dns_privacy_android_3": "<0>Intra</0> adds <1>DNS-over-HTTPS</1> support to Android.",
diff --git a/client/src/components/ui/Guide.js b/client/src/components/ui/Guide.js
index 0cb738d8..ec629e1b 100644
--- a/client/src/components/ui/Guide.js
+++ b/client/src/components/ui/Guide.js
@@ -1,10 +1,43 @@
 import React, { useState } from 'react';
 import PropTypes from 'prop-types';
-import { Trans, withTranslation } from 'react-i18next';
-
+import { Trans, useTranslation } from 'react-i18next';
+import i18next from 'i18next';
 import Tabs from './Tabs';
 import Icons from './Icons';
 
+const MOBILE_CONFIG_LINKS = {
+    DOT: '/apple/dot.mobileconfig',
+    DOH: '/apple/doh.mobileconfig',
+};
+
+const renderMobileconfigInfo = ({ label, components }) => <li key={label}>
+    <Trans components={components}>{label}</Trans>
+    <ul>
+        <li>
+            <a href={MOBILE_CONFIG_LINKS.DOT} download>{i18next.t('download_mobileconfig_dot')}</a>
+        </li>
+        <li>
+            <a href={MOBILE_CONFIG_LINKS.DOH} download>{i18next.t('download_mobileconfig_doh')}</a>
+        </li>
+    </ul>
+</li>;
+
+const renderLi = ({ label, components }) => <li key={label}>
+    <Trans components={components?.map((props) => {
+        if (React.isValidElement(props)) {
+            return props;
+        }
+        const {
+            // eslint-disable-next-line react/prop-types
+            href, target = '_blank', rel = 'noopener noreferrer', key = '0',
+        } = props;
+
+        return <a href={href} target={target} rel={rel} key={key}>link</a>;
+    })}>
+        {label}
+    </Trans>
+</li>;
+
 const dnsPrivacyList = [{
     title: 'Android',
     list: [
@@ -36,6 +69,23 @@ const dnsPrivacyList = [{
 {
     title: 'iOS',
     list: [
+        {
+            label: 'setup_dns_privacy_ios_2',
+            components: [
+                {
+                    key: 0,
+                    href: 'https://adguard.com/adguard-ios/overview.html',
+                },
+                <code key="1">text</code>,
+            ],
+        },
+        {
+            label: 'setup_dns_privacy_4',
+            components: {
+                highlight: <code />,
+            },
+            renderComponent: renderMobileconfigInfo,
+        },
         {
             label: 'setup_dns_privacy_ios_1',
             components: [
@@ -51,16 +101,6 @@ const dnsPrivacyList = [{
 
             ],
         },
-        {
-            label: 'setup_dns_privacy_ios_2',
-            components: [
-                {
-                    key: 0,
-                    href: 'https://adguard.com/adguard-ios/overview.html',
-                },
-                    <code key="1">text</code>,
-            ],
-        },
     ],
 },
 {
@@ -116,26 +156,15 @@ const dnsPrivacyList = [{
 },
 ];
 
-const renderDnsPrivacyList = ({ title, list }) => <div className="tab__paragraph">
+const renderDnsPrivacyList = ({ title, list }) => <div className="tab__paragraph" key={title}>
     <strong><Trans>{title}</Trans></strong>
-    <ul>{list.map(({ label, components }) => <li key={label}>
-        <Trans
-            components={components?.map((props) => {
-                if (React.isValidElement(props)) {
-                    return props;
-                }
-                const {
-                    // eslint-disable-next-line react/prop-types
-                    href, target = '_blank', rel = 'noopener noreferrer', key = '0',
-                } = props;
-
-                return <a
-                    href={href} target={target}
-                    rel={rel} key={key}>link</a>;
-            })}>
-            {label}
-        </Trans>
-    </li>)}
+    <ul>{list.map(
+        ({
+            label,
+            components,
+            renderComponent = renderLi,
+        }) => renderComponent({ label, components }),
+    )}
     </ul>
 </div>;
 
@@ -195,8 +224,8 @@ const getTabs = ({
     },
     dns_privacy: {
         title: 'dns_privacy',
-        // eslint-disable-next-line react/display-name
-        getTitle: () => <div label="dns_privacy" title={t('dns_privacy')}>
+        getTitle: function Title() {
+            return <div label="dns_privacy" title={t('dns_privacy')}>
             <div className="tab__text">
                 {tlsAddress?.length > 0 && (
                     <div className="tab__paragraph">
@@ -251,14 +280,15 @@ const getTabs = ({
                         {dnsPrivacyList.map(renderDnsPrivacyList)}
                     </>}
             </div>
-        </div>,
+        </div>;
+        },
     },
 });
 
-const renderContent = ({ title, list, getTitle }, t) => <div key={title} label={t(title)}>
-    <div className="tab__title">{t(title)}</div>
+const renderContent = ({ title, list, getTitle }) => <div key={title} label={i18next.t(title)}>
+    <div className="tab__title">{i18next.t(title)}</div>
     <div className="tab__text">
-        {typeof getTitle === 'function' && getTitle()}
+        {getTitle?.()}
         {list
         && <ol>{list.map((item) => <li key={item}>
             <Trans>{item}</Trans>
@@ -267,9 +297,10 @@ const renderContent = ({ title, list, getTitle }, t) => <div key={title} label={
     </div>
 </div>;
 
-const Guide = ({ dnsAddresses, t }) => {
-    const tlsAddress = (dnsAddresses && dnsAddresses.filter((item) => item.includes('tls://'))) || '';
-    const httpsAddress = (dnsAddresses && dnsAddresses.filter((item) => item.includes('https://'))) || '';
+const Guide = ({ dnsAddresses }) => {
+    const { t } = useTranslation();
+    const tlsAddress = dnsAddresses?.filter((item) => item.includes('tls://')) ?? '';
+    const httpsAddress = dnsAddresses?.filter((item) => item.includes('https://')) ?? '';
     const showDnsPrivacyNotice = httpsAddress.length < 1 && tlsAddress.length < 1;
 
     const [activeTabLabel, setActiveTabLabel] = useState('Router');
@@ -281,7 +312,7 @@ const Guide = ({ dnsAddresses, t }) => {
         t,
     });
 
-    const activeTab = renderContent(tabs[activeTabLabel], t);
+    const activeTab = renderContent(tabs[activeTabLabel]);
 
     return (
         <div>
@@ -298,12 +329,12 @@ Guide.defaultProps = {
 
 Guide.propTypes = {
     dnsAddresses: PropTypes.array,
-    t: PropTypes.func.isRequired,
 };
 
 renderDnsPrivacyList.propTypes = {
     title: PropTypes.string.isRequired,
     list: PropTypes.array.isRequired,
+    renderList: PropTypes.func,
 };
 
 renderContent.propTypes = {
@@ -312,4 +343,11 @@ renderContent.propTypes = {
     getTitle: PropTypes.func,
 };
 
-export default withTranslation()(Guide);
+renderLi.propTypes = {
+    label: PropTypes.string,
+    components: PropTypes.string,
+};
+
+renderMobileconfigInfo.propTypes = renderLi.propTypes;
+
+export default Guide;
diff --git a/go.mod b/go.mod
index 12616abe..8aaefd0d 100644
--- a/go.mod
+++ b/go.mod
@@ -19,6 +19,7 @@ require (
 	github.com/mdlayher/raw v0.0.0-20191009151244-50f2db8cc065
 	github.com/miekg/dns v1.1.31
 	github.com/pkg/errors v0.9.1
+	github.com/satori/go.uuid v1.2.0
 	github.com/sirupsen/logrus v1.6.0 // indirect
 	github.com/sparrc/go-ping v0.0.0-20190613174326-4e5b6552494c
 	github.com/stretchr/testify v1.5.1
@@ -31,4 +32,5 @@ require (
 	google.golang.org/protobuf v1.25.0 // indirect
 	gopkg.in/natefinch/lumberjack.v2 v2.0.0
 	gopkg.in/yaml.v2 v2.3.0
+	howett.net/plist v0.0.0-20200419221736-3b63eb3a43b5
 )
diff --git a/go.sum b/go.sum
index bfc5b5a2..9b45f53e 100644
--- a/go.sum
+++ b/go.sum
@@ -210,6 +210,8 @@ github.com/rogpeppe/go-internal v1.1.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFR
 github.com/rogpeppe/go-internal v1.3.0 h1:RR9dF3JtopPvtkroDZuVD7qquD0bnHlKSqaQhgwt8yk=
 github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
 github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g=
+github.com/satori/go.uuid v1.2.0 h1:0uYX9dsZ2yD7q2RtLRtPSdGDWzjeM3TbMJP9utgA0ww=
+github.com/satori/go.uuid v1.2.0/go.mod h1:dA0hQrYB0VpLJoorglMZABFdXlWrHn1NEOzdhQKdks0=
 github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo=
 github.com/shirou/gopsutil v2.20.3+incompatible h1:0JVooMPsT7A7HqEYdydp/OfjSOYSjhXV7w1hkKj/NPQ=
 github.com/shirou/gopsutil v2.20.3+incompatible/go.mod h1:5b4v6he4MtMOwMlS0TUMTu2PcXUg8+E1lC7eC3UO/RA=
@@ -430,6 +432,8 @@ honnef.co/go/tools v0.0.0-20180728063816-88497007e858/go.mod h1:rf3lG4BRIbNafJWh
 honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
 honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
+howett.net/plist v0.0.0-20200419221736-3b63eb3a43b5 h1:AQkaJpH+/FmqRjmXZPELom5zIERYZfwTjnHpfoVMQEc=
+howett.net/plist v0.0.0-20200419221736-3b63eb3a43b5/go.mod h1:vMygbs4qMhSZSc4lCUl2OEE+rDiIIJAIdR4m7MiMcm0=
 rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
 rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=
 sourcegraph.com/sourcegraph/go-diff v0.5.0/go.mod h1:kuch7UrkMzY0X+p9CRK03kfuPQ2zzQcaEFbx8wA8rck=
diff --git a/home/control.go b/home/control.go
index e8b2fa8a..78789aa8 100644
--- a/home/control.go
+++ b/home/control.go
@@ -97,8 +97,11 @@ func registerControlHandlers() {
 	httpRegister(http.MethodGet, "/control/i18n/current_language", handleI18nCurrentLanguage)
 	http.HandleFunc("/control/version.json", postInstall(optionalAuth(handleGetVersionJSON)))
 	httpRegister(http.MethodPost, "/control/update", handleUpdate)
+	httpRegister(http.MethodGet, "/control/profile", handleGetProfile)
 
-	httpRegister("GET", "/control/profile", handleGetProfile)
+	// No auth is necessary for DOH/DOT configurations
+	http.HandleFunc("/apple/doh.mobileconfig", postInstall(handleMobileConfigDoh))
+	http.HandleFunc("/apple/dot.mobileconfig", postInstall(handleMobileConfigDot))
 	RegisterAuthHandlers()
 }
 
diff --git a/home/dns.go b/home/dns.go
index bd70ce38..5328bf96 100644
--- a/home/dns.go
+++ b/home/dns.go
@@ -197,6 +197,44 @@ func generateServerConfig() dnsforward.ServerConfig {
 	return newconfig
 }
 
+type DNSEncryption struct {
+	https string
+	tls   string
+	quic  string
+}
+
+func getDNSEncryption() DNSEncryption {
+	dnsEncryption := DNSEncryption{}
+
+	tlsConf := tlsConfigSettings{}
+
+	Context.tls.WriteDiskConfig(&tlsConf)
+
+	if tlsConf.Enabled && len(tlsConf.ServerName) != 0 {
+
+		if tlsConf.PortHTTPS != 0 {
+			addr := tlsConf.ServerName
+			if tlsConf.PortHTTPS != 443 {
+				addr = fmt.Sprintf("%s:%d", addr, tlsConf.PortHTTPS)
+			}
+			addr = fmt.Sprintf("https://%s/dns-query", addr)
+			dnsEncryption.https = addr
+		}
+
+		if tlsConf.PortDNSOverTLS != 0 {
+			addr := fmt.Sprintf("tls://%s:%d", tlsConf.ServerName, tlsConf.PortDNSOverTLS)
+			dnsEncryption.tls = addr
+		}
+
+		if tlsConf.PortDNSOverQUIC != 0 {
+			addr := fmt.Sprintf("quic://%s:%d", tlsConf.ServerName, tlsConf.PortDNSOverQUIC)
+			dnsEncryption.quic = addr
+		}
+	}
+
+	return dnsEncryption
+}
+
 // Get the list of DNS addresses the server is listening on
 func getDNSAddresses() []string {
 	dnsAddresses := []string{}
@@ -217,28 +255,15 @@ func getDNSAddresses() []string {
 		addDNSAddress(&dnsAddresses, config.DNS.BindHost)
 	}
 
-	tlsConf := tlsConfigSettings{}
-	Context.tls.WriteDiskConfig(&tlsConf)
-	if tlsConf.Enabled && len(tlsConf.ServerName) != 0 {
-
-		if tlsConf.PortHTTPS != 0 {
-			addr := tlsConf.ServerName
-			if tlsConf.PortHTTPS != 443 {
-				addr = fmt.Sprintf("%s:%d", addr, tlsConf.PortHTTPS)
-			}
-			addr = fmt.Sprintf("https://%s/dns-query", addr)
-			dnsAddresses = append(dnsAddresses, addr)
-		}
-
-		if tlsConf.PortDNSOverTLS != 0 {
-			addr := fmt.Sprintf("tls://%s:%d", tlsConf.ServerName, tlsConf.PortDNSOverTLS)
-			dnsAddresses = append(dnsAddresses, addr)
-		}
-
-		if tlsConf.PortDNSOverQUIC != 0 {
-			addr := fmt.Sprintf("quic://%s:%d", tlsConf.ServerName, tlsConf.PortDNSOverQUIC)
-			dnsAddresses = append(dnsAddresses, addr)
-		}
+	dnsEncryption := getDNSEncryption()
+	if dnsEncryption.https != "" {
+		dnsAddresses = append(dnsAddresses, dnsEncryption.https)
+	}
+	if dnsEncryption.tls != "" {
+		dnsAddresses = append(dnsAddresses, dnsEncryption.tls)
+	}
+	if dnsEncryption.quic != "" {
+		dnsAddresses = append(dnsAddresses, dnsEncryption.quic)
 	}
 
 	return dnsAddresses
diff --git a/home/mobileconfig.go b/home/mobileconfig.go
new file mode 100644
index 00000000..e828f117
--- /dev/null
+++ b/home/mobileconfig.go
@@ -0,0 +1,92 @@
+package home
+
+import (
+	"fmt"
+	"net/http"
+
+	uuid "github.com/satori/go.uuid"
+	"howett.net/plist"
+)
+
+type DNSSettings struct {
+	DNSProtocol string
+	ServerURL   string `plist:",omitempty"`
+	ServerName  string `plist:",omitempty"`
+}
+
+type PayloadContent = struct {
+	Name               string
+	PayloadDescription string
+	PayloadDisplayName string
+	PayloadIdentifier  string
+	PayloadType        string
+	PayloadUUID        string
+	PayloadVersion     int
+	DNSSettings        DNSSettings
+}
+
+type MobileConfig = struct {
+	PayloadContent           []PayloadContent
+	PayloadDescription       string
+	PayloadDisplayName       string
+	PayloadIdentifier        string
+	PayloadRemovalDisallowed bool
+	PayloadType              string
+	PayloadUUID              string
+	PayloadVersion           int
+}
+
+func genUUIDv4() string {
+	return uuid.NewV4().String()
+}
+
+func getMobileConfig(r *http.Request, d DNSSettings) ([]byte, error) {
+	name := fmt.Sprintf("%s DNS over %s", r.Host, d.DNSProtocol)
+
+	data := MobileConfig{
+		PayloadContent: []PayloadContent{{
+			Name:               name,
+			PayloadDescription: "Configures device to use AdGuard Home",
+			PayloadDisplayName: name,
+			PayloadIdentifier:  fmt.Sprintf("com.apple.dnsSettings.managed.%s", genUUIDv4()),
+			PayloadType:        "com.apple.dnsSettings.managed",
+			PayloadUUID:        genUUIDv4(),
+			PayloadVersion:     1,
+			DNSSettings:        d,
+		}},
+		PayloadDescription:       "Adds AdGuard Home to Big Sur and iOS 14 or newer systems",
+		PayloadDisplayName:       name,
+		PayloadIdentifier:        genUUIDv4(),
+		PayloadRemovalDisallowed: false,
+		PayloadType:              "Configuration",
+		PayloadUUID:              genUUIDv4(),
+		PayloadVersion:           1,
+	}
+
+	return plist.MarshalIndent(data, plist.XMLFormat, "\t")
+}
+
+func handleMobileConfig(w http.ResponseWriter, r *http.Request, d DNSSettings) {
+	mobileconfig, err := getMobileConfig(r, d)
+
+	if err != nil {
+		httpError(w, http.StatusInternalServerError, "plist.MarshalIndent: %s", err)
+	}
+
+	w.Header().Set("Content-Type", "application/xml")
+	_, _ = w.Write(mobileconfig)
+}
+
+func handleMobileConfigDoh(w http.ResponseWriter, r *http.Request) {
+	handleMobileConfig(w, r, DNSSettings{
+		DNSProtocol: "HTTPS",
+		ServerURL:   fmt.Sprintf("https://%s/dns-query", r.Host),
+	})
+}
+
+func handleMobileConfigDot(w http.ResponseWriter, r *http.Request) {
+	handleMobileConfig(w, r, DNSSettings{
+		DNSProtocol: "TLS",
+		ServerName:  r.Host,
+	})
+}
diff --git a/openapi/openapi.yaml b/openapi/openapi.yaml
index c50d9485..6ad140e2 100644
--- a/openapi/openapi.yaml
+++ b/openapi/openapi.yaml
@@ -35,6 +35,8 @@ tags:
       description: AdGuard Home statistics
     - name: tls
       description: AdGuard Home HTTPS/DOH/DOT settings
+    - name: mobileconfig
+      description: Apple .mobileconfig
 
 paths:
     /status:
@@ -915,6 +917,27 @@ paths:
                         application/json:
                             schema:
                                 $ref: "#/components/schemas/ProfileInfo"
+    /apple/doh.mobileconfig:
+        get:
+            tags:
+                - mobileconfig
+                - global
+            operationId: mobileConfigDoH
+            summary: Get DNS over HTTPS .mobileconfig
+            responses:
+                "200":
+                    description: DNS over HTTPS plist file
+
+    /apple/dot.mobileconfig:
+        get:
+            tags:
+                - mobileconfig
+                - global
+            operationId: mobileConfigDoT
+            summary: Get TLS over TLS .mobileconfig
+            responses:
+                "200":
+                    description: DNS over TLS plist file
 
 components:
     requestBodies: