From cfb814d006dca9737f1635773f73660eccec9ffc Mon Sep 17 00:00:00 2001
From: Nathan Woodburn <github@nathan.woodburn.au>
Date: Tue, 28 Jan 2025 16:46:06 +1100
Subject: [PATCH] feat: Add public info and troubleshooting modules

---
 plugins/public_info.py                     |  41 ++++
 plugins/renewal.py                         |   2 +-
 plugins/troubleshooting.py                 | 237 +++++++++++++++++++++
 templates/components/dashboard-plugin.html |   2 +-
 4 files changed, 280 insertions(+), 2 deletions(-)
 create mode 100644 plugins/public_info.py
 create mode 100644 plugins/troubleshooting.py

diff --git a/plugins/public_info.py b/plugins/public_info.py
new file mode 100644
index 0000000..54012da
--- /dev/null
+++ b/plugins/public_info.py
@@ -0,0 +1,41 @@
+import json
+import account
+import requests
+
+# Plugin Data
+info = {
+    "name": "Public Node Dashboard",
+    "description": "Dashboard modules for public nodes",
+    "version": "1.0",
+    "author": "Nathan.Woodburn/"
+}
+
+# Functions
+functions = {
+    "main":{
+        "name": "Info Dashboard widget",
+        "type": "dashboard",
+        "description": "This creates the widget that shows on the dashboard",
+        "params": {},
+        "returns": {
+            "status": 
+            {
+                "name": "Status of Node",
+                "type": "text"
+            }
+        }
+    }
+}
+
+def main(params, authentication):
+    info = account.hsd.getInfo()
+
+    status = f"Version: {info['version']}<br>Inbound Connections: {info['pool']['inbound']}<br>Outbound Connections: {info['pool']['outbound']}<br>"
+    if info['pool']['public']['listen']:
+        status += f"Public Node: Yes<br>Host: {info['pool']['public']['host']}<br>Port: {info['pool']['public']['port']}<br>"
+    else:
+        status += f"Public Node: No<br>"
+    status += f"Agent: {info['pool']['agent']}<br>Services: {info['pool']['services']}<br>"
+
+    return {"status": status}
+    
\ No newline at end of file
diff --git a/plugins/renewal.py b/plugins/renewal.py
index fe9b2d8..9a9f850 100644
--- a/plugins/renewal.py
+++ b/plugins/renewal.py
@@ -88,7 +88,7 @@ def main(params, authentication):
             if batch["error"] != "":
                 print("Failed to verify batch",flush=True)
                 print(batch["error"]["message"],flush=True)
-                return {"status": "Failed", "transaction": "None"}
+                return {"status": f"Failed: {batch['error']['message']}", "transaction": "None"}
         
         if 'result' in batch:
             if batch['result'] != None:
diff --git a/plugins/troubleshooting.py b/plugins/troubleshooting.py
new file mode 100644
index 0000000..a5325e8
--- /dev/null
+++ b/plugins/troubleshooting.py
@@ -0,0 +1,237 @@
+import json
+import account
+import requests
+
+import dns.resolver
+import dns.message
+import dns.query
+import dns.rdatatype
+import dns.rrset
+from cryptography import x509
+from cryptography.hazmat.backends import default_backend
+import tempfile
+import subprocess
+import binascii
+import datetime
+import dns.asyncresolver
+import httpx
+from requests_doh import DNSOverHTTPSSession, add_dns_provider
+import domainLookup
+
+doh_url = "https://hnsdoh.com/dns-query"
+
+# Plugin Data
+info = {
+    "name": "Troubleshooting",
+    "description": "Various troubleshooting functions",
+    "version": "1.0",
+    "author": "Nathan.Woodburn/"
+}
+
+# Functions
+functions = {
+    "dig":{
+        "name": "DNS Lookup",
+        "type": "default",
+        "description": "Do DNS lookups on a domain",
+        "params": {
+            "domain": {
+                "name":"Domain to lookup (eg. woodburn)",
+                "type":"text"
+            },
+            "type": {
+                "name":"Type of lookup (A,TXT,NS,DS,TLSA)",
+                "type":"text"
+            }
+        },
+        "returns": {
+            "result": 
+            {
+                "name": "Result",
+                "type": "list"
+            }
+        }
+    },
+    "https_check":{
+        "name": "HTTPS Check",
+        "type": "default",
+        "description": "Check if a domain has an HTTPS certificate",
+        "params": {
+            "domain": {
+                "name":"Domain to lookup (eg. woodburn)",
+                "type":"text"
+            }
+        },
+        "returns": {
+            "result": 
+            {
+                "name": "Result",
+                "type": "text"
+            }
+        }
+    },
+    "hip_lookup": {
+        "name": "Hip Lookup",
+        "type": "default",
+        "description": "Look up a domain's hip address",
+        "params": {
+            "domain": {
+                "name": "Domain to lookup",
+                "type": "text"
+            }
+        },
+        "returns": {
+            "result": {
+                "name": "Result",
+                "type": "text"
+            }
+        }
+    }
+}
+
+def dns_request(domain: str, rType:str) -> list[dns.rrset.RRset]:
+    if rType == "":
+        rType = "A"
+    rType = dns.rdatatype.from_text(rType.upper())
+
+
+    with httpx.Client() as client:
+        q = dns.message.make_query(domain, rType)
+        r = dns.query.https(q, doh_url, session=client)
+        return r.answer
+
+
+def dig(params, authentication):
+    domain = params["domain"]
+    type = params["type"]
+    result: list[dns.rrset.RRset] = dns_request(domain, type)
+    print(result)
+    if result:
+        if len(result) == 1:
+            result: dns.rrset.RRset = result[0]
+            result = result.items
+            return {"result": result}
+
+        else:
+            return {"result": result}
+    else:
+        return {"result": ["No result"]}
+    
+
+
+def https_check(params, authentication):
+    domain = params["domain"]
+    domain_check = False
+    try:        
+        # Get the IP
+        ip = list(dns_request(domain,"A")[0].items.keys())
+        if len(ip) == 0:
+            return {"result": "No IP found"}
+        ip = ip[0]
+        print(ip)
+        
+        # Run the openssl s_client command
+        s_client_command = ["openssl","s_client","-showcerts","-connect",f"{ip}:443","-servername",domain,]
+
+        s_client_process = subprocess.Popen(s_client_command, stdout=subprocess.PIPE, stderr=subprocess.PIPE, stdin=subprocess.PIPE)
+        s_client_output, _ = s_client_process.communicate(input=b"\n")
+        
+        certificates = []
+        current_cert = ""
+        for line in s_client_output.split(b"\n"):
+            current_cert += line.decode("utf-8") + "\n"
+            if "-----END CERTIFICATE-----" in line.decode("utf-8"):
+                certificates.append(current_cert)
+                current_cert = ""
+
+        # Remove anything before -----BEGIN CERTIFICATE-----
+        certificates = [cert[cert.find("-----BEGIN CERTIFICATE-----"):] for cert in certificates]
+
+        if certificates:
+            cert = certificates[0]
+
+            with tempfile.NamedTemporaryFile(mode="w", delete=False) as temp_cert_file:
+                temp_cert_file.write(cert)
+                temp_cert_file.seek(0)  # Move back to the beginning of the temporary file
+
+            tlsa_command = ["openssl","x509","-in",temp_cert_file.name,"-pubkey","-noout","|","openssl","pkey","-pubin","-outform","der","|","openssl","dgst","-sha256","-binary",]
+            
+            tlsa_process = subprocess.Popen(" ".join(tlsa_command), shell=True, stdout=subprocess.PIPE)
+            tlsa_output, _ = tlsa_process.communicate()
+
+            tlsa_server = "3 1 1 " + binascii.hexlify(tlsa_output).decode("utf-8")
+            print(f"TLSA Server: {tlsa_server}")
+
+
+            # Get domains
+            cert_obj = x509.load_pem_x509_certificate(cert.encode("utf-8"), default_backend())
+
+            domains = []
+            for ext in cert_obj.extensions:
+                if ext.oid == x509.ExtensionOID.SUBJECT_ALTERNATIVE_NAME:
+                    san_list = ext.value.get_values_for_type(x509.DNSName)
+                    domains.extend(san_list)
+            
+            # Extract the common name (CN) from the subject
+            common_name = cert_obj.subject.get_attributes_for_oid(x509.NameOID.COMMON_NAME)
+            if common_name:
+                if common_name[0].value not in domains:
+                    domains.append(common_name[0].value)
+
+            if domains:
+                if domain in domains:
+                    domain_check = True
+                else:
+                    # Check if matching wildcard domain exists
+                    for d in domains:
+                        if d.startswith("*"):
+                            if domain.split(".")[1:] == d.split(".")[1:]:
+                                domain_check = True
+                                break
+        
+
+            expiry_date = cert_obj.not_valid_after
+            # Check if expiry date is past
+            if expiry_date < datetime.datetime.now():
+                return {"result": "Certificate is expired"}
+        else:
+            return {"result": "No certificate found"}
+
+        try:
+            # Check for TLSA record
+            tlsa = dns_request(f"_443._tcp.{domain}","TLSA")
+            tlsa = list(tlsa[0].items.keys())
+            if len(tlsa) == 0:
+                return {"result": "No TLSA record found"}
+            tlsa = tlsa[0]
+            print(f"TLSA: {tlsa}")
+
+            if not tlsa:
+                return {"result": "TLSA lookup failed"}
+            else:
+                if tlsa_server == str(tlsa):
+                    if domain_check:
+                        add_dns_provider("HNSDoH", "https://hnsdoh.com/dns-query")
+
+                        session = DNSOverHTTPSSession("HNSDoH")
+                        r = session.get(f"https://{domain}/",verify=False)
+                        if r.status_code != 200:
+                            return {"result": "Webserver returned status code: " + str(r.status_code)}
+                        return {"result": "HTTPS check successful"}
+                    else:
+                        return {"result": "TLSA record matches certificate, but domain does not match certificate"}
+                
+                else:
+                    return {"result": "TLSA record does not match certificate"}
+        
+        except Exception as e:
+            return {"result": "TLSA lookup failed with error: " + str(e)}
+
+    # Catch all exceptions
+    except Exception as e:
+        return {"result": "Lookup failed.<br><br>Error: " + str(e)}
+    
+def hip_lookup(params, authentication):
+    domain = params["domain"]
+    hip = domainLookup.hip2(domain)
+    return {"result": hip}
\ No newline at end of file
diff --git a/templates/components/dashboard-plugin.html b/templates/components/dashboard-plugin.html
index c6fdb75..12aeb15 100644
--- a/templates/components/dashboard-plugin.html
+++ b/templates/components/dashboard-plugin.html
@@ -2,7 +2,7 @@
     <div class="card shadow border-start-warning py-2">
         <div class="card-body">
             <div class="text-uppercase fw-bold text-xs mb-1"><span style="color: var(--bs-dark);">{{name}}</span></div>
-            <div class="text-dark fw-bold h5 mb-0"><span>{{output}}</span></div>
+            <div class="text-dark fw-bold h5 mb-0"><span>{{output | safe}}</span></div>
         </div>
     </div>
 </div>
\ No newline at end of file