diff --git a/README.md b/README.md new file mode 100644 index 0000000..5234fb5 --- /dev/null +++ b/README.md @@ -0,0 +1,24 @@ +![Project Logo](static/image.png) + +# DNS-Tool + Tool to compare all DNS records for many different nameservers. Useful for checking DNS propagation of records and discovering all DNS records of a domain. + +## Installation +1. Clone the repository: +```bash + git clone https://github.com/ImShyMike/DNS-Tool.git +``` + +2. Install dependencies: +```bash + pip install -r requirements.txt + ``` + +## Usage + To run the flask app run: +```bash + python main.py + ``` + +## License +This project is licensed under the [MIT License](LICENSE). \ No newline at end of file diff --git a/dns_servers.json b/dns_servers.json new file mode 100644 index 0000000..fa37026 --- /dev/null +++ b/dns_servers.json @@ -0,0 +1,12 @@ +{ + "Google": ["8.8.8.8", "8.8.4.4"], + "Cloudflare": ["1.1.1.1", "1.0.0.1"], + "OpenDNS Home": ["208.67.222.222", "208.67.220.220"], + "Quad9": ["9.9.9.9", "149.112.112.112"], + "NextDNS": ["45.90.28.188", "45.90.30.188"], + "Neustar": ["156.154.70.1", "156.154.71.1"], + "Norton ConnectSafe": ["199.85.126.10", "199.85.127.10"], + "Comodo Secure": ["8.26.56.26", "8.20.247.20"], + "NordVPN": ["103.86.96.100", "103.86.99.100"], + "Verisign": ["64.6.64.6", "64.6.65.6"] +} \ No newline at end of file diff --git a/main.py b/main.py new file mode 100644 index 0000000..eb0aecc --- /dev/null +++ b/main.py @@ -0,0 +1,428 @@ +"""Website backend file""" + +import asyncio +import ipaddress +import json +import logging +import os +import re +import sys +import time +from collections import defaultdict +from functools import wraps + +import dns +import dns.asyncresolver +import dns.resolver +import requests +from flask import ( + Flask, + abort, + jsonify, + make_response, + render_template, + send_from_directory, + request, +) +from flask_limiter import Limiter +from flask_limiter.util import get_remote_address +from werkzeug.wrappers import Request as WerkzeugRequest + +CACHE_FILE = "cloudflare_ips.cache" +CACHE_DURATION = 60 * 60 * 24 # 24 hours + +MAX_CONCURRENT_QUERIES = 10 + +STATIC_FOLDER = "static" + +SELECTED_RDATA_TYPES = [ + "A", "AAAA", "AFSDB", "APL", "AXFR", "CAA", "CDNSKEY", "CDS", "CERT", "CNAME", + "CSYNC", "DHCID", "DLV", "DNAME", "DNSKEY", "DS", "EUI48", "EUI64", "HINFO", + "HIP", "HTTPS", "IPSECKEY", "IXFR", "KEY", "KX", "LOC", "MX", "NAPTR", "NS", + "NSEC3", "NSEC3PARAM", "NSEC", "NXT", "OPENPGPKEY", "OPT", "PTR", "RP", "RRSIG", + "SIG", "SMIMEA", "SOA", "SPF", "SSHFP", "SVCB", "SRV", "TA", "TKEY", "TLSA", + "TSIG", "TXT", "URI", "ZONEMD" +] # List of most commonly used types (not actually all, thats just too much D:) + +ips_being_used = [] + + +# Create the flask app +app = Flask(__name__) + +# Create a limiter instance +limiter = Limiter(get_remote_address, app=app, default_limits=["3 per second"]) + + +def disable_same_ip_concurrency(func): + """Make it so each ip can only have one request processing at a time""" + @wraps(func) + def wrapper(*args, **kwargs): + ip = request.remote_addr + if ip in ips_being_used: + abort(429) # Forbidden + ips_being_used.append(ip) + try: + return func(*args, **kwargs) + finally: + ips_being_used.remove(ip) + + return wrapper + + +def split_and_strip_str(string): + """Split a string into lists and stip each one""" + return [substr.strip() for substr in string.split()] + + +def get_cloudflare_ips(): + """Get all ips that belong to cloudflare using a cache""" + if os.path.exists(CACHE_FILE): + with open(CACHE_FILE, "r", encoding="utf-8") as f: + cache_time, ipv4, ipv6 = f.read().split("---") + cache_time, ipv4, ipv6 = ( + cache_time.strip(), + split_and_strip_str(ipv4), + split_and_strip_str(ipv6), + ) + if time.time() - float(cache_time) < CACHE_DURATION: + return ipv4, ipv6 + + try: + cloudflare_ipv4 = requests.get( + "https://www.cloudflare.com/ips-v4", timeout=5 + ).text.split("\n") + cloudflare_ipv6 = requests.get( + "https://www.cloudflare.com/ips-v6", timeout=5 + ).text.split("\n") + except requests.RequestException as e: + print(f"Error fetching Cloudflare IPs: {e}") + if os.path.exists(CACHE_FILE): + with open(CACHE_FILE, "r", encoding="utf-8") as f: + cache_time, ipv4, ipv6 = f.read().split("---") + ipv4, ipv6 = split_and_strip_str(ipv4), split_and_strip_str(ipv6) + return ipv4, ipv6 + else: + logging.critical( + "Unable to fetch cloudflare IP ranges and no cache is available. Aborting..." + ) + sys.exit(1) + + with open(CACHE_FILE, "w", encoding="utf-8") as f: + f.write(f"{time.time()}\n---\n") + f.write("\n".join(cloudflare_ipv4) + "\n---\n") + f.write("\n".join(cloudflare_ipv6)) + + return cloudflare_ipv4, cloudflare_ipv6 + + +# Get Cloudflare IPs +cf_ipv4, cf_ipv6 = get_cloudflare_ips() + +# Load the list of nameservers from a JSON file +with open("dns_servers.json", "r", encoding="utf8") as file: + nameserver_list = json.load(file) + +# Make a resolver for each nameserver +resolvers = {} +for name, nameserver in nameserver_list.items(): + resolver = dns.asyncresolver.Resolver(configure=False) + resolver.cache = dns.resolver.Cache(10) + resolver.timeout = 2 + resolver.lifetime = 2 + resolver.nameservers = nameserver + resolvers[name] = resolver + + +def handle_cloudflare_request(req): + """Handle a cloudflare request and set needed environ flags""" + # Extract Cloudflare headers + ip = req.headers.get("Cf-Connecting-IP", None) + ipv6 = req.headers.get("Cf-Connecting-IPv6", None) + country = req.headers.get("Cf-Ipcountry", None) + + is_ipv4 = ip and (":" not in ip) + + # I do not need to detect this, atleast not with cloudflared.exe (its always cloudflare) + # req.environ['cf_addr'] = req.remote_addr + # req.environ['cf_request'] = True + + # Store Cloudflare details in WSGI environ for future access + req.environ["REMOTE_ADDR"] = ( + ip if ip and is_ipv4 else (ipv6 if ipv6 else req.remote_addr) + ) # Use IPv4 if available, else use IPv6 (fallback to remote_addr if the request is local) + req.environ["country"] = country + + +class CloudflareMiddleware: + """Middleware for the Werkzeug app to process Cloudflare headers""" + def __init__(self, wz_app): + self.app = wz_app + + def __call__(self, environ, start_response): + # Create a Werkzeug request object from the WSGI environment + req = WerkzeugRequest(environ) + + # Handle Cloudflare request + handle_cloudflare_request(req) + + # Proceed with the rest of the request + return self.app(environ, start_response) + + +def is_cloudflare_request(remote_addr): + """Make sure the request is comming from cloudflare""" + # Check if remote_addr is in the Cloudflare IP ranges + try: + ip = ipaddress.ip_address(remote_addr) + return any(ip in ipaddress.ip_network(net) for net in cf_ipv4 + cf_ipv6) + except ValueError: + return False + + +def is_valid_dns_query(query: str, allow_wildcard: bool = True) -> bool: + """Check if the dns query parameter is valid""" + # Remove the leading "*." for validation (if it exists) + if allow_wildcard and query.startswith("*."): + query = query[2:] + # Regex for a valid DNS query (excluding wildcards) + pattern = re.compile(r"^(?!-)[A-Za-z0-9-]{1,63}(? 253: + return False + # Use regex to match the pattern + if not pattern.match(query): + return False + return True + + +async def query_single_type(semaphore, domain, query_type, dns_resolver): + """Query a single DNS record type for a given domain using the provided resolver.""" + async with semaphore: + try: + start_time = time.perf_counter() + response = await dns_resolver.resolve(domain, query_type) + end_time = time.perf_counter() + return { + "type": query_type, # dns.rdatatype.to_text(query_type) + "owner": response.canonical_name.relativize(domain).to_text( + omit_final_dot=True + ), + "data": [str(rdata) for rdata in response], + "expiry": response.expiration, + "ping": round((end_time - start_time) * 1000, 2), + } + except dns.exception.DNSException as e: + if e == "DNS metaqueries are not allowed.": + return {"error": "ratelimited"} + return None + + +async def query_domain_multi_nameserver(domain, rdata_types, ns_list): + """Query a domain for all DNS record types concurrently across all nameservers.""" + # Prepare the semaphore for limiting concurrent requests + semaphore = asyncio.Semaphore(MAX_CONCURRENT_QUERIES) + + # Prepare tasks for each nameserver and each DNS record type + tasks = [] + for nameserver_name in ns_list.keys(): + # Create a task for each record type and nameserver + dns_resolver = resolvers[nameserver_name] + for record_type in rdata_types: + tasks.append( + query_single_type(semaphore, domain, record_type, dns_resolver) + ) + + # Run all tasks concurrently and gather the results + results = await asyncio.gather(*tasks) + + # Organize results by nameserver and record type + organized_results = {} + for idx, (nameserver_name, _) in enumerate(ns_list.items()): + organized_results[nameserver_name] = [ + result + for result in results[idx * len(rdata_types) : (idx + 1) * len(rdata_types)] + if result + ] + + return organized_results + + +def compare_responses(results): + """Compare results and return the comparison""" + record_sets = defaultdict( + lambda: defaultdict(lambda: {"data": set(), "owner": set(), "expiry": set()}) + ) + nameservers = list(results.keys()) + + # Collect all unique records along with their owner and expiration information + for ns, records in results.items(): + for record in records: + if "error" in record: + continue + + record_type = record["type"] + data_key = tuple(sorted(record["data"])) + + # Collect data, owner, and expiry information + record_sets[record_type][data_key]["data"].add(ns) + record_sets[record_type][data_key]["owner"].add(record["owner"]) + record_sets[record_type][data_key]["expiry"].add(record["expiry"]) + + comparison = {"matching": {}, "outliers": defaultdict(dict)} + + # Identify matching records and outliers, including owner and expiration info + for record_type, data_sets in record_sets.items(): + if len(data_sets) == 1: + # All nameservers agree + data_key = list(data_sets.keys())[0] + data_info = data_sets[data_key] + comparison["matching"][record_type] = { + "data": list(data_key), + "expiry": list(data_info["expiry"]), + "owner": data_info["owner"].pop(), + "full_match": True, + } + else: + # There are outliers + max_agreement = max(len(info["data"]) for info in data_sets.values()) + for data_key, data_info in data_sets.items(): + if len(data_info["data"]) == max_agreement: + comparison["matching"][record_type] = { + "data": list(data_key), + "expiry": list(data_info["expiry"]), + "owner": data_info["owner"].pop(), + "full_match": len(data_info["data"]) == len(nameservers), + "agreeing_nameservers": list(data_info["data"]), + "total_nameservers": len(nameservers), + } + else: + comparison["outliers"][record_type][ + ", ".join(data_info["data"]) + ] = { + "data": list(data_key), + "expiry": list(data_info["expiry"]), + "owner": data_info["owner"].pop(), + } + + return comparison + + +def get_found_records(results): + """Get all found records from the results""" + record_types = [] + for _, ns in results.items(): + for record in ns: + record_type = record["type"] + if record_type not in record_types: + record_types.append(record_type) + return record_types + + +@app.route("/dns-query", methods=["POST"]) +@limiter.limit("8 per minute") +@disable_same_ip_concurrency +def dns_query(): + """Regular dns query""" + chosen_domain = request.json.get("domain") + if is_valid_dns_query(chosen_domain): + print(f"{request.remote_addr} - {chosen_domain}") + results = asyncio.run( + query_domain_multi_nameserver( + chosen_domain, SELECTED_RDATA_TYPES, nameserver_list + ) + ) + comparison = compare_responses(results) + return jsonify( + { + "results": results, + "comparison": comparison, + "types": get_found_records(results), + } + ) + print(f"BLOCKED - {request.remote_addr} - QUERY - {chosen_domain}") + return make_response(jsonify({"error": "Invalid domain"}), 400) + + +@app.route("/dns-requery", methods=["POST"]) +@limiter.limit("20 per minute") +@disable_same_ip_concurrency +def dns_requery(): + """Requery a set of dns record""" + chosen_domain = request.json.get("domain") + chosen_rdata_types = request.json.get("types", []) + filtered_rdata_types = list( + set( + [ + rdata_type + for rdata_type in chosen_rdata_types + if rdata_type in SELECTED_RDATA_TYPES + ] + ) + ) + if is_valid_dns_query(chosen_domain): + if not filtered_rdata_types: + return make_response(jsonify({"error": "Invalid query types"}), 400) + print( + f"{request.remote_addr} - REQUERY - " \ + f"Amount: {len(filtered_rdata_types)} - {chosen_domain}" + ) + results = asyncio.run( + query_domain_multi_nameserver( + chosen_domain, filtered_rdata_types, nameserver_list + ) + ) + comparison = compare_responses(results) + return jsonify( + { + "results": results, + "comparison": comparison, + "types": get_found_records(results), + } + ) + print( + f"BLOCKED - {request.remote_addr} - REQUERY - " \ + f"Amount: {len(filtered_rdata_types)} - {chosen_domain}" + ) + return make_response(jsonify({"error": "Invalid domain"}), 400) + + +@app.route("/", methods=["GET"]) +def index(): + """Root website page""" + return render_template( + "index.html", + nameserver_list=nameserver_list, + rdata_types=SELECTED_RDATA_TYPES, + ) + + +@app.route("/static/", methods=["GET"]) +def static_serve(path): + """Serve a static file with caching""" + response = make_response(send_from_directory(STATIC_FOLDER, path)) + response.headers["Cache-Control"] = "public, max-age=54000" # Add a 15 minute cache + return response + + +@app.route("/favicon.ico", methods=["GET"]) +def static_favicon(): + """Serve a the favicon with caching""" + response = make_response(send_from_directory(STATIC_FOLDER, "favicon.ico")) + response.headers["Cache-Control"] = "public, max-age=54000" # Add a 15 minute cache + return response + + +# @app.before_request +# def before_request_handler(): +# request.country = request.environ.get("country") +# print(f"Request is from: {request.country}") + +if __name__ == "__main__": + # Wrap the Flask app with the middleware + app.wsgi_app = CloudflareMiddleware(app.wsgi_app) + + # Run the flask app + app.run("0.0.0.0", port=8080, debug=False) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..88baee9 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +Flask>=2.3.2 +flask-limiter>=2.10.0 +dnspython>=2.4.2 +requests>=2.31.0 +Werkzeug>=2.3.7 \ No newline at end of file diff --git a/static/favicon.ico b/static/favicon.ico new file mode 100644 index 0000000..62d274c Binary files /dev/null and b/static/favicon.ico differ diff --git a/static/image.png b/static/image.png new file mode 100644 index 0000000..8602aa1 Binary files /dev/null and b/static/image.png differ diff --git a/static/script.js b/static/script.js new file mode 100644 index 0000000..9a692e1 --- /dev/null +++ b/static/script.js @@ -0,0 +1,186 @@ +const queryButton = document.getElementById('queryButton'); +const requeryButton = document.getElementById('requeryButton'); +const domainInput = document.getElementById('domainInput'); +var lastDomain = ""; +var lastRecordTypes = []; + +domainInput.addEventListener('keydown', async (event) => { + if (event.key === 'Enter') { + if (lastRecordTypes && lastDomain == domainInput.value) { + await queryDomain(requeryButton, lastRecordTypes); + } else { + await queryDomain(queryButton); + } + } +}); + +queryButton.addEventListener('click', async () => { + await queryDomain(queryButton); +}); + +requeryButton.addEventListener('click', async () => { + if (lastRecordTypes && lastDomain == domainInput.value) { + await queryDomain(requeryButton, lastRecordTypes); + } else { + await queryDomain(queryButton); + } + +}); + +async function queryDomain(button, types) { + const domain = document.getElementById('domainInput').value; + const responseContainer = document.getElementById('responseContainer'); + var loadingSpinner = null; + if (types === undefined) { + loadingSpinner = document.getElementById('loadingSpinner2'); + } else { + loadingSpinner = document.getElementById('loadingSpinner'); + } + responseContainer.innerHTML = ''; + + loadingSpinner.style.display = 'block'; + button.style.color = window.getComputedStyle(loadingSpinner).backgroundColor;; + button.classList.add('loading'); + button.disabled = true; + + try { + let response; + const requestOptions = { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(types === undefined ? { domain } : { domain, types }) + }; + + response = await fetch(types === undefined ? '/dns-query' : '/dns-requery', requestOptions); + + if (response && response.status === 429) { + responseContainer.innerHTML = '
Error: Ratelimited, wait before trying again!
'; + cleanupAfterRequest(loadingSpinner, button); + return; + } + + const data = await response.json(); + lastDomain = domain; + + if (data.error !== undefined) { + responseContainer.innerHTML = `
Error: ${data.error}
`; + lastRecordTypes = []; + } else { + displayResults(data.comparison); + lastRecordTypes = data.types; + } + + } catch (error) { + if (error.name === 'TypeError') { + responseContainer.innerHTML = '
Error: Network error or server did not respond!
'; + } else { + responseContainer.innerHTML = `
Error: ${error.message}
`; + } + } + cleanupAfterRequest(loadingSpinner, button); +} + +function cleanupAfterRequest(loadingSpinner, button) { + loadingSpinner.style.display = 'none'; + button.style.color = "rgb(49, 50, 68)"; + button.classList.remove('loading'); + button.disabled = false; +} + +function displayResults(comparison) { + const responseContainer = document.getElementById('responseContainer'); + responseContainer.innerHTML = ''; + + // Display matching records + const matchingTable = createMatchingTable('Matching Records', comparison.matching); + responseContainer.appendChild(matchingTable); + + // Display outliers + if (Object.keys(comparison.outliers).length > 0) { + const outliersTable = createOutliersTable('Outliers', comparison.outliers); + responseContainer.appendChild(outliersTable); + } +} + +function getRemainingTime(targetTimestamp) { + const now = new Date().getTime(); + const targetTime = new Date(targetTimestamp).getTime(); + const remainingTime = targetTime - now; + + if (remainingTime <= 0) return "0s"; + + const hours = Math.floor((remainingTime / (1000 * 60 * 60)) % 24); + const minutes = Math.floor((remainingTime / (1000 * 60)) % 60); + const seconds = Math.floor((remainingTime / 1000) % 60); + + var formattedTime = String(seconds) + "s"; + if (minutes > 0) { + formattedTime = String(minutes) + "m" + formattedTime + if (hours > 0) { + formattedTime = String(hours) + "h" + formattedTime + } + } + + return formattedTime; + } + +function createMatchingTable(title, data) { + const table = document.createElement('table'); + table.innerHTML = ` + + ${title} + + + Record Type + Data + Owner + Match Status + + `; + + for (const [recordType, recordInfo] of Object.entries(data)) { + const row = table.insertRow(); + row.innerHTML = ` + ${recordType} + ${recordInfo.data.join(',
')} + ${recordInfo.owner} + ${recordInfo.full_match ? 'Full Match' : `Partial Match (${recordInfo.agreeing_nameservers.length}/${recordInfo.total_nameservers} nameservers)`} + `; // ${getRemainingTime(recordInfo.expiry * 1000)} + if (!recordInfo.full_match) { + row.classList.add('partial-match'); + } + } + + return table; +} + +function createOutliersTable(title, data) { + const table = document.createElement('table'); + table.innerHTML = ` + + ${title} + + + Record Type + Nameservers + Data + TTL + Owner + + `; + + for (const [recordType, outliers] of Object.entries(data)) { + for (const [nameservers, recordDetails] of Object.entries(outliers)) { + const row = table.insertRow(); + row.innerHTML = ` + ${recordType} + ${nameservers} + ${recordDetails.data.join(',
')} + ${getRemainingTime(recordDetails.expiry * 1000)} + ${recordDetails.owner} + `; + } + } + + return table; +} \ No newline at end of file diff --git a/static/style.css b/static/style.css new file mode 100644 index 0000000..f05f213 --- /dev/null +++ b/static/style.css @@ -0,0 +1,212 @@ +body { + font-family: 'Rethink Sans', sans-serif; + background-color: rgb(35, 38, 52); + color: rgb(198, 208, 245); + margin: 0; + padding: 20px; +} + +.container { + max-width: 1100px; + margin: auto; + background: rgb(48, 52, 70); + padding: 20px; + border-radius: 5px; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + position: relative; +} + +.input-group { + display: flex; + width: 100%; +} + +#domainInput { + flex: 1; + padding: 10px; + border: 1px solid rgb(165, 173, 206); + background-color: rgb(98, 104, 128); + color: rgb(198, 208, 245); + border-top-left-radius: 5px; + border-bottom-left-radius: 5px; + font-size: 16px; +} + +.queryButton { + padding: 10px 20px; + background-color: rgb(166, 209, 137); + border: 1px solid rgb(165, 173, 206); + color: rgb(49, 50, 68); + border: none; + border-top-right-radius: 5px; + border-bottom-right-radius: 5px; + cursor: pointer; + font-size: 16px; + transition: background-color 0.3s ease; + position: relative; +} + +#requeryButton { + border-top-right-radius: 0px !important; + border-bottom-right-radius: 0px !important; + background-color: rgb(140, 170, 238); +} + +#requeryButton:hover { + background-color: rgb(107, 129, 179); +} + +.queryButton:hover { + background-color: rgb(129, 161, 107); +} + +#queryButton.loading { + background-color: rgb(166, 209, 137); + cursor: not-allowed; +} + +#requeryButton.loading { + background-color: rgb(140, 170, 238); + cursor: not-allowed; +} + +.loadingSpinner { + border: 3px solid rgb(166, 209, 137); + border-top: 3px solid rgb(107, 129, 179); + border-radius: 50%; + width: 16px; + height: 16px; + animation: spin 1s linear infinite; + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: none; +} + +#loadingSpinner { + border: 3px solid rgb(140, 170, 238) !important; + border-top: 3px solid rgb(129, 161, 107) !important; +} + +@keyframes spin { + 0% { + transform: translate(-50%, -50%) rotate(0deg); + } + + 100% { + transform: translate(-50%, -50%) rotate(360deg); + } +} + +table { + width: 100%; + border-collapse: collapse; + table-layout: auto; + margin-top: 20px; +} + +.responseContainer { + overflow-x: auto; +} + +th, +td { + border: 1px solid rgb(165, 173, 206); + padding: 8px; + text-align: left; +} + +th { + background-color: rgb(69, 71, 90); +} + +th[colspan="3"] { + background-color: #e0e0e0; + text-align: center; +} + +.partial-match { + background-color: rgb(150, 122, 64); +} + +.error { + color: rgb(243, 139, 168); +} + +.popup-trigger { + cursor: pointer; + padding: 10px; + background-color: rgb(131, 139, 167); + display: inline-block; + text-decoration: none; + color: rgb(49, 50, 68); + position: absolute; + right: 20px; + top: 20px; + width: 20px; + height: 20px; + line-height: 20px; + border-radius: 50%; + font-size: 1.3rem; + text-align: center; +} + +.popup { + display: none; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + background-color: rgba(0, 0, 0, 0.5); + overflow: auto; +} + +.popup-content { + background-color: rgb(48, 52, 70); + width: 300px; + padding: 20px; + margin: 100px auto; + border-radius: 5px; +} + +#popup:target { + display: block; +} + +.close-popup { + display: block; + margin-top: 20px; + text-align: center; + background-color: rgb(166, 209, 137); + color: rgb(49, 50, 68); + text-decoration: none; + padding: 10px; +} + +.collapsible { + display: flex; + align-items: center; + line-height: 1.7rem; +} + +.collapsible h2 { + margin: 0; + padding-right: 10px; +} + +.collapsible::-webkit-details-marker { + display: none; +} + +.collapsible::before { + content: "▶"; + display: inline-block; + margin-right: 5px; + transition: transform 0.2s; +} + +details[open]>.collapsible::before { + transform: rotate(90deg); +} \ No newline at end of file diff --git a/templates/index.html b/templates/index.html new file mode 100644 index 0000000..0610307 --- /dev/null +++ b/templates/index.html @@ -0,0 +1,74 @@ + + + + + + + DNS Tool + + + + + + + + + + + + + + + + + + +
+

DNS Query / Compare Tool

+
+ + + +
+ + 🛈 + + + +
+ +
+
+ + +