#! /usr/bin/env python3 import base64 import csv import os import subprocess import sys from glob import glob INCOMPATIBLE_WITH_LEGACY_VERSIONS = ["cira-family", "cira-private", "cira-protected"] CURRENT_DIR = "v3" LEGACY_DIR = "v2" HISTORIC_DIR = "v1" MINISIGN_PK = "RWQf6LRCGA9i53mlYecO4IzT51TGPpvWucNSCh1CBM0QTaLn73Y7GFO3" class Entry: name = None description = None stamps = None def __init__(self, name, description, stamps): self.name = name self.description = description self.stamps = stamps @staticmethod def parse(raw_entry): description = "" stamps = [] lines = raw_entry.strip().splitlines() if len(lines) < 2: return None name = lines[0].strip() previous_was_blank = False for line in lines[1:]: line = line.strip() if previous_was_blank is True and line == "": continue previous_was_blank = False if line.startswith("sdns://"): stamps.append(line) else: description = description + line + "\n" description = description.strip() if len(name) < 2 or len(description) < 10 or len(stamps) < 1: return None return Entry(name, description, stamps) def format(self): out = "## " + self.name + "\n\n" out = out + self.description + "\n\n" for stamp in self.stamps: out = out + stamp + "\n" return out def format_legacy(self): out = "## " + self.name + "\n\n" out = out + self.description + "\n\n" out = out + self.stamps[0] + "\n" return out def csv_entry(self): parsed = DNSCryptStamp.parse(self.stamps[0]) if parsed is None: return None version = 2 if self.name.find("cisco") >= 0: version = 1 dnssec = "no" nolog = "no" namecoin = "no" if parsed.dnssec: dnssec = "yes" if parsed.nolog: nolog = "yes" csv_entry = [ self.name, self.name, self.description.splitlines(False)[0], None, None, None, version, dnssec, nolog, namecoin, parsed.addr, parsed.provider, parsed.pk, None, ] return csv_entry class DNSCryptStamp: dnssec = False nolog = False nofilter = False addr = None pk = None provider = None @staticmethod def parse(stamp): bin = base64.urlsafe_b64decode(stamp.removeprefix("sdns://") + "==") i = 0 if bin[i] != 0x01: return None i = i + 1 parsed = DNSCryptStamp() props = bin[i] parsed.dnssec = not not ((props >> 0) & 1) parsed.nolog = not not ((props >> 1) & 1) parsed.nofilter = not not ((props >> 2) & 1) i = i + 8 addr_len = bin[i] i = i + 1 parsed.addr = bin[i : i + addr_len].decode("utf-8") i = i + addr_len pk_len = bin[i] i = i + 1 if pk_len != 32: return None hpk = bin[i : i + pk_len].hex().upper() hpks = [] for j in range(0, 16): hpks.append(hpk[j * 4 : j * 4 + 4]) parsed.pk = ":".join(hpks) i = i + pk_len provider_len = bin[i] i = i + 1 parsed.provider = bin[i : i + provider_len].decode("utf-8") i = i + provider_len return parsed def process(md_path, signatures_to_update): md_legacy_path = LEGACY_DIR + "/" + os.path.basename(md_path) csv_historic_path = HISTORIC_DIR + "/" + "dnscrypt-resolvers.csv" print("\n[" + md_path + "]") entries = {} previous_content = "" out = "" out_legacy = """ # *** THIS LIST IS FOR OLD DNSCRYPT-PROXY VERSIONS *** Version 2 of the list is for dnscrypt-proxy <= 2.0.42 users. If you are running up-to-date software, replace `/v2/` with `/v3/` in the sources URLs of the `dnscrypt-proxy.toml` file (relevant lines start with `urls = ['https://...']` and are present in the `[sources]` section). THIS LIST IS AUTOMATICALLY GENERATED AS A SUBSET OF THE V3 LIST. DO NOT EDIT IT MANUALLY. If you want to contribute changes to a resolvers list, only edit files from the `v3` directory. -- """ csv_entries = [] with open(md_path) as f: previous_content = f.read() c = previous_content.split("\n## ") out = out + c[0].strip() + "\n\n" raw_entries = c[1:] for i in range(0, len(raw_entries)): entry = Entry.parse(raw_entries[i]) if not entry: print("Invalid entry: [" + raw_entries[i] + "]", file=sys.stderr) continue if entry.name in entries: print("Duplicate entry: [" + entry.name + "]", file=sys.stderr) entries[entry.name] = entry for name in sorted(entries.keys()): entry = entries[name] out = out + "\n" + entry.format() + "\n" if not name in INCOMPATIBLE_WITH_LEGACY_VERSIONS: out_legacy = out_legacy + "\n" + entry.format_legacy() + "\n" if os.path.basename(md_path) == "public-resolvers.md": for name in sorted(entries.keys()): entry = entries[name] csv_entry = entry.csv_entry() if csv_entry: csv_entries.append(entry.csv_entry()) if out == previous_content: print("No changes") else: with open(md_path + ".tmp", "wt") as f: f.write(out) os.rename(md_path + ".tmp", md_path) # Legacy with open(md_legacy_path) as f: previous_content = f.read() if out_legacy == previous_content: print("No changes to the legacy version") else: with open(md_legacy_path + ".tmp", "wt") as f: f.write(out_legacy) os.rename(md_legacy_path + ".tmp", md_legacy_path) # Historic if len(csv_entries) != 0: with open(csv_historic_path, "wt") as f: w = csv.writer(f, dialect="unix", quoting=csv.QUOTE_MINIMAL) w.writerow( [ "Name", "Full name", "Description", "Location", "Coordinates", "URL", "Version", "DNSSEC validation", "No logs", "Namecoin", "Resolver address", "Provider name", "Provider public key", "Provider public key TXT record", ] ) for csv_entry in csv_entries: w.writerow(csv_entry) # Signatures for path in [md_path, md_legacy_path, csv_historic_path]: try: subprocess.run( ["minisign", "-V", "-P", MINISIGN_PK, "-m", path], check=True ) except subprocess.CalledProcessError: signatures_to_update.append(path) signatures_to_update = [] for md_path in glob(CURRENT_DIR + "/*.md"): process(md_path, signatures_to_update) if signatures_to_update: subprocess.run(["minisign", "-Sm", *signatures_to_update])