From bc85ea63ab59513e2afbaa85e3550769e27c918d Mon Sep 17 00:00:00 2001 From: PorridgePi Date: Sun, 8 Oct 2023 02:45:45 +0800 Subject: [PATCH] feat: initial commit --- config-sample.yaml | 20 ++++ doh-mobileconfig.py | 259 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) create mode 100644 config-sample.yaml create mode 100644 doh-mobileconfig.py diff --git a/config-sample.yaml b/config-sample.yaml new file mode 100644 index 0000000..5b415cf --- /dev/null +++ b/config-sample.yaml @@ -0,0 +1,20 @@ +identifier: com.example.doh +individualConfig: true + +devices: +- name: iPhone + uuid: e124199a-3777-4652-b75f-d87dfe407031 + +dns: + - name: Cloudflare + url: https://cloudflare-dns.com/dns-query + includeDeviceInfo: false + formatDeviceInfo: true + - name: Quad9 + url: https://dns.quad9.net/dns-query + includeDeviceInfo: false + formatDeviceInfo: true + + +excluded_ssids: + - MyWifiSSID diff --git a/doh-mobileconfig.py b/doh-mobileconfig.py new file mode 100644 index 0000000..4066e73 --- /dev/null +++ b/doh-mobileconfig.py @@ -0,0 +1,259 @@ +# requires httpx, dnspython +import re, uuid, yaml, requests, argparse, urllib.parse, base64, httpx, dns.message, dns.query, dns.rdatatype + + + +parser = argparse.ArgumentParser() +parser.add_argument("-p", "--print", action="store_true", help="print formatted device information and URLs") +parser.add_argument("--suffix", action="store", help="sets a suffix to device information, defaults to '-wan'") +parser.add_argument("-c", "--config", action="store", help="specify custom location to config.yaml") +args = parser.parse_args() + +# https://stackoverflow.com/a/54254115 +def isValidUUID(val): + try: + uuid.UUID(str(val)) + return True + except ValueError: + return False + +# https://stackoverflow.com/a/7160778 +def isValidURL(url): + regex = re.compile( + r'^(?:http|ftp)s?://' # http:// or https:// + r'(?:(?:[A-Z0-9](?:[A-Z0-9-]{0,61}[A-Z0-9])?\.)+(?:[A-Z]{2,6}\.?|[A-Z0-9-]{2,}\.?)|' #domain... + r'localhost|' #localhost... + r'\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})' # ...or ip + r'(?::\d+)?' # optional port + r'(?:/?|[/?]\S+)$', re.IGNORECASE) + return(re.match(regex, str(url)) is not None) + +mobileConfigTemplate = """ + + + + + PayloadContent + +REPLACE_DNS_RECORDS + + PayloadDescription + Adds encrypted DNS configurations (DNS-over-HTTPS or DNS-over-TLS) to macOS 11.0 Big Sur (or newer) and iOS 14 (or newer) based systems + PayloadDisplayName + Personal DoH Settings + PayloadIdentifier + REPLACE_IDENTIFIER + PayloadRemovalDisallowed + + PayloadType + Configuration + PayloadUUID + REPLACE_UUID + PayloadVersion + 1 + + +""" + +EDNSRecordTemplate = """ + + DNSSettings + + DNSProtocol + HTTPS + ServerAddresses + + ServerURL + REPLACE_SERVER_URL + + OnDemandRules + + + Action + Disconnect + SSIDMatch + +REPLACE_EXCLUDED_SSID + + + + Action + Connect + InterfaceTypeMatch + WiFi + + + Action + Connect + InterfaceTypeMatch + Cellular + + + Action + Disconnect + + + PayloadDescription + Configures device to use REPLACE_DNS_NAME DNS-over-HTTPS + PayloadDisplayName + REPLACE_DNS_NAME DoH + PayloadIdentifier + com.apple.dnsSettings.managed.REPLACE_UUID + PayloadType + com.apple.dnsSettings.managed + PayloadUUID + REPLACE_UUID + PayloadVersion + 1 + ProhibitDisablement + + +""" + +if (args.config): + with open(args.config, 'r') as file: + config = yaml.safe_load(file) +else: + with open('config.yaml', 'r') as file: + config = yaml.safe_load(file) + +def isValidDOH(url): + try: + # resp = requests.get( + # url = url, + # params = {"name": "one.one.one.one", "type": "A"}, + # headers = {"accept": "application/dns-json"} + # ) + # for ans in resp.json()["Answer"]: + # if ans["data"] == "1.1.1.1" or ans["data"] == "1.0.0.1": + # return True + + # reqRaw = dns.message.make_query("one.one.one.one", 'A', id=0).to_wire() + # reqStr = base64.urlsafe_b64encode(reqRaw).decode('utf-8').rstrip('=') + # resp = requests.get( + # url = url, + # params = {"dns": reqStr}, + # headers = {"accept": "application/dns-message"} + # ) + # respDecoded = str(dns.message.from_wire(resp.content)) + + # if ("1.1.1.1" in respDecoded or "1.0.0.1" in respDecoded): + # return True + # else: + # print(resp) + # print(respDecoded) + # return False + + with httpx.Client(follow_redirects=True) as client: + q = dns.message.make_query("one.one.one.one", dns.rdatatype.A) + r = dns.query.https(q, url, session=client) + for answer in r.answer: + if ("1.1.1.1" in str(answer) or "1.0.0.1" in str(answer)): + return True + else: + print(answer) + return False + except Exception as e: + print(e) + return False + + +def formatDeviceInfo(deviceName, deviceUUID, toFormat = True, addSuffix = True): + if (args.suffix is not None): + suffix = args.suffix + suffix = re.sub(r"[^\w\s]", '', suffix) + suffix = re.sub(r"\s+", '-', suffix) + else: + suffix = "wan" + + if (not isValidUUID(deviceUUID)): + print("ERROR: Invalid device UUID!") + print(" Device:", deviceName) + print(" UUID:", device["uuid"]) + exit() + if (toFormat): + deviceName = deviceName.lower() + # https://stackoverflow.com/a/1007615 + # Remove all non-word characters (everything except numbers and letters) + deviceName = re.sub(r"[^\w\s]", '', deviceName) + # Replace all runs of whitespace with a single dash + deviceName = re.sub(r"\s+", '-', deviceName) + if (addSuffix): + return deviceUUID + '-' + deviceName + '-' + suffix + else: + return deviceUUID + '-' + deviceName + else: + return urllib.parse.quote(deviceName + ' (' + deviceUUID + ')', safe='') + +def newUUID4(match): + return str(uuid.uuid4()) + +def main(): + newRecords = [] + for device in config["devices"]: + if (config["individualConfig"]): + newRecords = [] + + for dns in config["dns"]: + url = dns["url"] + if (not isValidURL(url)): + print("ERROR: Invalid URL!") + print(" URL:", url) + exit() + if (url[-1] == '/'): + url = url[:-1] + + if (dns["includeDeviceInfo"]): + url += '/' if (url[-1] != '/') else '' + urlPath = formatDeviceInfo(device["name"], device["uuid"], dns["formatDeviceInfo"]) + url = url + urlPath + + if (not isValidDOH(url)): + print("ERROR: Invalid DOH URL!") + print(" URL:", url) + exit() + + print(url) + + excludedSSIDs = [] + for ssid in config["excluded_ssids"]: + excludedSSIDs.append(" " + ssid + "") + excludedSSIDs = '\n'.join(excludedSSIDs) + newRecord = EDNSRecordTemplate + newRecord = re.sub(r"REPLACE_EXCLUDED_SSID", excludedSSIDs, newRecord) + newRecord = re.sub(r"REPLACE_SERVER_URL", url, newRecord) + + if (dns["includeDeviceInfo"]): + newRecord = re.sub(r"REPLACE_DNS_NAME", dns["name"] + ' (' + device["name"] + ')', newRecord) + newRecords.append(newRecord) + else: + newRecord = re.sub(r"REPLACE_DNS_NAME", dns["name"], newRecord) + if (newRecord not in newRecords): + newRecords.append(newRecord) + + + if (config["individualConfig"]): + newRecords = "\n".join(newRecords) + + mobileConfig = mobileConfigTemplate + mobileConfig = re.sub(r"REPLACE_IDENTIFIER", config["identifier"] + '-' + formatDeviceInfo(device["name"], device["uuid"], True, False), mobileConfig) + mobileConfig = re.sub(r"REPLACE_DNS_RECORDS", newRecords, mobileConfig) + mobileConfig = re.sub(r"REPLACE_UUID", newUUID4, mobileConfig) + with open(formatDeviceInfo(device["name"], device["uuid"], True, False) + '-' + "DoH.mobileconfig", "w") as file: + file.write(mobileConfig) + + if (not config["individualConfig"]): + newRecords = "\n".join(newRecords) + + mobileConfig = mobileConfigTemplate + mobileConfig = re.sub(r"REPLACE_IDENTIFIER", config["identifier"], mobileConfig) + mobileConfig = re.sub(r"REPLACE_DNS_RECORDS", newRecords, mobileConfig) + mobileConfig = re.sub(r"REPLACE_UUID", newUUID4, mobileConfig) + with open("DoH.mobileconfig", "w") as file: + file.write(mobileConfig) + +if (args.print): + for device in config["devices"]: + print(formatDeviceInfo(device["name"], device["uuid"], True)) +else: + main() \ No newline at end of file