From d9e03f087b3106d5cdfce9dbb68770d91d497835 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:05:44 -0400 Subject: [PATCH 01/41] initial commit - start drafting it out --- nfsn-ddns.py | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 nfsn-ddns.py diff --git a/nfsn-ddns.py b/nfsn-ddns.py new file mode 100644 index 0000000..5929d9a --- /dev/null +++ b/nfsn-ddns.py @@ -0,0 +1,28 @@ +import requests +import argparse +import os +import ipaddress + +os.getenv('IP_PROVIDER', "http://ipinfo.io/ip") +os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") +os.getenv('USERNAME', "http://v6.ipinfo.io/ip") +os.getenv('API_KEY', "http://v6.ipinfo.io/ip") +os.getenv('DOMAIN', "http://v6.ipinfo.io/ip") +# os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") +# os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") + + + + + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='automate the updating of domain records to create Dynamic DNS for domains registered with NearlyFreeSpeech.net') + # parser.add_argument('integers', metavar='N', type=int, nargs='+', + # help='an integer for the accumulator') + # parser.add_argument('--sum', dest='accumulate', action='store_const', + # const=sum, default=max, + # help='sum the integers (default: find the max)') + + args = parser.parse_args() + print(args.accumulate(args.integers)) From 14af7731616b822bfffb45703f56ec0cceddf5ed Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:11:36 -0400 Subject: [PATCH 02/41] set up some typings --- nfsn-ddns.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 5929d9a..31f9b5e 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -1,7 +1,11 @@ import requests import argparse import os -import ipaddress +from ipaddress import IPv4Address, IPv6Address +from typing import Union, NewType + +IPAddress = NewType("IPAddress", Union[IPv4Address, IPv6Address]) + os.getenv('IP_PROVIDER', "http://ipinfo.io/ip") os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") From c27d1e41058dc5f0253ffeda72cff6d3df157f93 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:11:49 -0400 Subject: [PATCH 03/41] recreate the doIPsMatch helper --- nfsn-ddns.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 31f9b5e..f916998 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -18,6 +18,9 @@ +def doIPsMatch(ip1:IPAddress, ip2:IPAddress): + return ip1 == ip2 + if __name__ == "__main__": From 74413a26712208cdb9c25a02eefce8c5f68f3e96 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:14:48 -0400 Subject: [PATCH 04/41] replace randomRangeString helper --- nfsn-ddns.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index f916998..4f01b7b 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -3,6 +3,8 @@ import os from ipaddress import IPv4Address, IPv6Address from typing import Union, NewType +import random +import string IPAddress = NewType("IPAddress", Union[IPv4Address, IPv6Address]) @@ -16,6 +18,10 @@ # os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") +def randomRangeString(length:int) -> str: + character_options = string.ascii_uppercase + string.ascii_lowercase + string.digits + random_values = [random.choice(character_options) for _ in range(length)] + return ''.join(random_values) def doIPsMatch(ip1:IPAddress, ip2:IPAddress): From df3c9a7eebd69a91266a9b8de1668c42e43110a3 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:15:04 -0400 Subject: [PATCH 05/41] add return type to doIPsMatch --- nfsn-ddns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 4f01b7b..5004877 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -24,7 +24,7 @@ def randomRangeString(length:int) -> str: return ''.join(random_values) -def doIPsMatch(ip1:IPAddress, ip2:IPAddress): +def doIPsMatch(ip1:IPAddress, ip2:IPAddress) -> bool: return ip1 == ip2 From 3909a4fe6504878c2cd920a1015ed7931cab1dd6 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:30:41 -0400 Subject: [PATCH 06/41] factor out NFSN api url into a variable --- nfsn-ddns.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 5004877..8c6e32c 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -17,6 +17,8 @@ # os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") # os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") +NFSN_API_DOMAIN = "https://api.nearlyfreespeech.net" + def randomRangeString(length:int) -> str: character_options = string.ascii_uppercase + string.ascii_lowercase + string.digits From 19b1fdd044ea23b2804a87a4484dd16ba1188cc0 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:35:44 -0400 Subject: [PATCH 07/41] reimplement helper for creating the NFSN auth header --- nfsn-ddns.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 8c6e32c..ce0bede 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -5,6 +5,8 @@ from typing import Union, NewType import random import string +from datetime import datetime, timezone +import hashlib IPAddress = NewType("IPAddress", Union[IPv4Address, IPv6Address]) @@ -31,6 +33,22 @@ def doIPsMatch(ip1:IPAddress, ip2:IPAddress) -> bool: +def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: + salt = randomRangeString(16) + timestamp = int(datetime.now(timezone.utc).time().time()) + uts = f"{nfsn_username};{timestamp};{salt}" + + body_hash = str(hashlib.sha1(bytes(body, 'utf-8'))) + + msg = f"{uts};{nfsn_apikey};{uri};{body_hash}" + + full_hash = str(hashlib.sha1(bytes(msg, 'utf-8'))) + + return {"X-NFSN-Authentication": f"{uts};{full_hash}"} + + + + if __name__ == "__main__": parser = argparse.ArgumentParser(description='automate the updating of domain records to create Dynamic DNS for domains registered with NearlyFreeSpeech.net') # parser.add_argument('integers', metavar='N', type=int, nargs='+', From 1a16fba3942fdffeba3365f045dd192e8ebabb1f Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:40:54 -0400 Subject: [PATCH 08/41] NFSN response validator --- nfsn-ddns.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index ce0bede..6cf6bd7 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -32,6 +32,13 @@ def doIPsMatch(ip1:IPAddress, ip2:IPAddress) -> bool: return ip1 == ip2 +def validateNFSNResponse(response): + if response.get("error") is not None: + timestamp = datetime.now().strftime("%y-%m-%d %H:%M:%S") + print(f"{timestamp}: ERROR: {response.get('error')}") + print(f"{timestamp}: ERROR: {response.get('debug')}") + + def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: salt = randomRangeString(16) From df835edd252392b32ad0cc7b494a455c6afcb9ac Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:43:11 -0400 Subject: [PATCH 09/41] reimplement function to make a HTTP request to NFSN --- nfsn-ddns.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 6cf6bd7..0ecf107 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -39,6 +39,18 @@ def validateNFSNResponse(response): print(f"{timestamp}: ERROR: {response.get('debug')}") +def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): + url = NFSN_API_DOMAIN + path + headers = createNFSNAuthHeader(nfsn_username, nfsn_apikey, url, body) + + response = requests.get(url, body=body, headers=headers) + + data = response.body() + + validateNFSNResponse(data) + + return data + def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: salt = randomRangeString(16) From 0ff616cbd02b777d604ff833d467d1e8de2ab1ec Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:44:46 -0400 Subject: [PATCH 10/41] addign IP provider values to variables --- nfsn-ddns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 0ecf107..53a984f 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -11,8 +11,8 @@ IPAddress = NewType("IPAddress", Union[IPv4Address, IPv6Address]) -os.getenv('IP_PROVIDER', "http://ipinfo.io/ip") -os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") +IPV4_PROVIDER_URL = os.getenv('IP_PROVIDER', "http://ipinfo.io/ip") +IPV6_PROVIDER_URL = os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") os.getenv('USERNAME', "http://v6.ipinfo.io/ip") os.getenv('API_KEY', "http://v6.ipinfo.io/ip") os.getenv('DOMAIN', "http://v6.ipinfo.io/ip") From 43e5e0e880eb7569d22bce688181a7438c66d4b0 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:46:46 -0400 Subject: [PATCH 11/41] implement fetch current IP --- nfsn-ddns.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 53a984f..928c299 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -51,6 +51,11 @@ def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): return data +def fetchCurrentIP(): + response = requests.get(IPV4_PROVIDER_URL) + response.raise_for_status() + return response.body().trim() + def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: salt = randomRangeString(16) From 5f6e4b0df56915b9a2aadd1511d57756c488fcb6 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:47:09 -0400 Subject: [PATCH 12/41] raise exception if the HTTP status isnt good when making an NFSN request --- nfsn-ddns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 928c299..cedb03c 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -44,9 +44,9 @@ def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): headers = createNFSNAuthHeader(nfsn_username, nfsn_apikey, url, body) response = requests.get(url, body=body, headers=headers) - - data = response.body() + response.raise_for_status() + data = response.body() validateNFSNResponse(data) return data From 676929e62df4772dc6e37a9c447ac2f482dda289 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:49:46 -0400 Subject: [PATCH 13/41] refactor output logging into a function --- nfsn-ddns.py | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index cedb03c..d9198bb 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -32,11 +32,16 @@ def doIPsMatch(ip1:IPAddress, ip2:IPAddress) -> bool: return ip1 == ip2 +def output(msg, type_msg=None): + timestamp = datetime.now().strftime("%y-%m-%d %H:%M:%S") + type_str = f"{type_msg}: " if type_msg is not None else "" + print(f"{timestamp}: {type_str}{msg}") + + def validateNFSNResponse(response): if response.get("error") is not None: - timestamp = datetime.now().strftime("%y-%m-%d %H:%M:%S") - print(f"{timestamp}: ERROR: {response.get('error')}") - print(f"{timestamp}: ERROR: {response.get('debug')}") + output(response.get('error'), type_msg="ERROR") + output(response.get('debug'), type_msg="ERROR") def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): From f043ac307b649d6bd1a9fa45e91d213dedba42d7 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:56:09 -0400 Subject: [PATCH 14/41] allow a custom timestamp to be passed into the logging function --- nfsn-ddns.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index d9198bb..b131b77 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -32,8 +32,9 @@ def doIPsMatch(ip1:IPAddress, ip2:IPAddress) -> bool: return ip1 == ip2 -def output(msg, type_msg=None): - timestamp = datetime.now().strftime("%y-%m-%d %H:%M:%S") +def output(msg, type_msg=None, timestamp=None): + if timestamp is None: + timestamp = datetime.now().strftime("%y-%m-%d %H:%M:%S") type_str = f"{type_msg}: " if type_msg is not None else "" print(f"{timestamp}: {type_str}{msg}") From 4cab54602335c6feb44cd3da246ad9c43003ef3c Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 20:56:34 -0400 Subject: [PATCH 15/41] fetch the current IP from NFSN --- nfsn-ddns.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index b131b77..29666bb 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -52,7 +52,7 @@ def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): response = requests.get(url, body=body, headers=headers) response.raise_for_status() - data = response.body() + data = response.json() validateNFSNResponse(data) return data @@ -62,6 +62,18 @@ def fetchCurrentIP(): response.raise_for_status() return response.body().trim() +def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): + path = f"/dns/{domain}/listRRs" + body = f"name={subdomain}" + + response_data = makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) + + if response_data == []: + output("No IP address is currently set.") + return "UNSET" + + return response_data[0].get("data") + def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: salt = randomRangeString(16) From c1edd8c7d667072984be149887d869554d5345cf Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:01:50 -0400 Subject: [PATCH 16/41] replaceDomain --- nfsn-ddns.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 29666bb..b59f8e3 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -75,6 +75,22 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): return response_data[0].get("data") + +def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): + path = f"/dns/{domain}/replaceRR" + body = f"name={subdomain}&type=A&data={current_ip}" + + if subdomain == "": + output(f"Setting {domain} to {current_ip}...") + else: + output(f"Setting {subdomain}.{domain} to {current_ip}...") + + response_data = makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) + + if response_data != {}: + validateNFSNResponse(response_data) + + def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: salt = randomRangeString(16) timestamp = int(datetime.now(timezone.utc).time().time()) From 61fac2ccbbaad58c93da606cd3b0d1edb74a5ea4 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:02:42 -0400 Subject: [PATCH 17/41] argparse isnt needed --- nfsn-ddns.py | 1 - 1 file changed, 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index b59f8e3..d78e0b6 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -1,5 +1,4 @@ import requests -import argparse import os from ipaddress import IPv4Address, IPv6Address from typing import Union, NewType From 9d82a33a9ff41189ca00c809d3468f2bddda2027 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:18:41 -0400 Subject: [PATCH 18/41] subdomain may not be present --- nfsn-ddns.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index d78e0b6..86d3ea0 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -77,7 +77,8 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): path = f"/dns/{domain}/replaceRR" - body = f"name={subdomain}&type=A&data={current_ip}" + name = f"name={subdomain}&" if subdomain else "" + body = f"{name}type=A&data={current_ip}" if subdomain == "": output(f"Setting {domain} to {current_ip}...") From 48959732ebc37ea192da911286121f1dc3a1e479 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:19:02 -0400 Subject: [PATCH 19/41] the rest of the owl --- nfsn-ddns.py | 60 +++++++++++++++++++++++++++++++++++++++------------- 1 file changed, 45 insertions(+), 15 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 86d3ea0..ffa8fa7 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -1,6 +1,6 @@ import requests import os -from ipaddress import IPv4Address, IPv6Address +from ipaddress import IPv4Address, IPv6Address, ip_address from typing import Union, NewType import random import string @@ -12,11 +12,6 @@ IPV4_PROVIDER_URL = os.getenv('IP_PROVIDER', "http://ipinfo.io/ip") IPV6_PROVIDER_URL = os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") -os.getenv('USERNAME', "http://v6.ipinfo.io/ip") -os.getenv('API_KEY', "http://v6.ipinfo.io/ip") -os.getenv('DOMAIN', "http://v6.ipinfo.io/ip") -# os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") -# os.getenv('IPV6_PROVIDER', "http://v6.ipinfo.io/ip") NFSN_API_DOMAIN = "https://api.nearlyfreespeech.net" @@ -106,14 +101,49 @@ def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str] +def updateIPs(domain, subdomain, domain_ip, current_ip, nfsn_username, nfsn_apikey): + # When there's no existing record for a domain name, the + # listRRs API query returns the domain name of the name server. + if domain_ip.startswith("nearlyfreespeech.net"): + output("The domain IP doesn't appear to be set yet.") + else: + output(f"Current IP: {current_ip} doesn't match Domain IP: {domain_ip}") + + replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey) + + # Check to see if the update was successful + + new_domain_ip = fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey) + + if doIPsMatch(ip_address(new_domain_ip), ip_address(current_ip)): + output(f"IPs match now! Current IP: {current_ip} Domain IP: {domain_ip}") + else: + output(f"They still don't match. Current IP: {current_ip} Domain IP: {domain_ip}") + + +def ensure_present(value, name): + if value is None: + raise ValueError(f"Please ensure {name} is set to a value before running this script") + + if __name__ == "__main__": - parser = argparse.ArgumentParser(description='automate the updating of domain records to create Dynamic DNS for domains registered with NearlyFreeSpeech.net') - # parser.add_argument('integers', metavar='N', type=int, nargs='+', - # help='an integer for the accumulator') - # parser.add_argument('--sum', dest='accumulate', action='store_const', - # const=sum, default=max, - # help='sum the integers (default: find the max)') - - args = parser.parse_args() - print(args.accumulate(args.integers)) + nfsn_username = os.getenv('USERNAME') + nfsn_apikey = os.getenv('API_KEY') + nfsn_domain = os.getenv('DOMAIN') + nfsn_subdomain = os.getenv('SUBDOMAIN') + + ensure_present(nfsn_username, "USERNAME") + ensure_present(nfsn_apikey, "API_KEY") + ensure_present(nfsn_domain, "DOMAIN") + + + domain_ip = fetchDomainIP(nfsn_domain, nfsn_subdomain, nfsn_username, nfsn_apikey) + current_ip = fetchCurrentIP() + + if doIPsMatch(ip_address(domain_ip), ip_address(current_ip)): + output(f"IPs still match! Current IP: {current_ip} Domain IP: {domain_ip}") + return + + updateIPs(nfsn_domain, nfsn_subdomain, domain_ip, current_ip, nfsn_username, nfsn_apikey) + From 256ce4565ded662d723c5d516085bb2c8f80a7cb Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:50:05 -0400 Subject: [PATCH 20/41] fix timestamp --- nfsn-ddns.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index ffa8fa7..d59cb3e 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -88,7 +88,7 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: salt = randomRangeString(16) - timestamp = int(datetime.now(timezone.utc).time().time()) + timestamp = int(datetime.now(timezone.utc).timestamp()) uts = f"{nfsn_username};{timestamp};{salt}" body_hash = str(hashlib.sha1(bytes(body, 'utf-8'))) From 5a7bfa4926f02621300fa1afa1e0c78fd5effe38 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:50:47 -0400 Subject: [PATCH 21/41] fix return outside function --- nfsn-ddns.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index d59cb3e..c89c468 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -143,7 +143,5 @@ def ensure_present(value, name): if doIPsMatch(ip_address(domain_ip), ip_address(current_ip)): output(f"IPs still match! Current IP: {current_ip} Domain IP: {domain_ip}") - return - - updateIPs(nfsn_domain, nfsn_subdomain, domain_ip, current_ip, nfsn_username, nfsn_apikey) - + else: + updateIPs(nfsn_domain, nfsn_subdomain, domain_ip, current_ip, nfsn_username, nfsn_apikey) From 78bccebbdb040c0bf9fb43deae36d0acd8bf505f Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:27:55 -0400 Subject: [PATCH 22/41] remove tcl code --- dns.tcl | 68 --------------------------- packages/http.tcl | 108 ------------------------------------------- packages/utility.tcl | 38 --------------- pkgIndex.tcl | 5 -- 4 files changed, 219 deletions(-) delete mode 100755 dns.tcl delete mode 100644 packages/http.tcl delete mode 100644 packages/utility.tcl delete mode 100644 pkgIndex.tcl diff --git a/dns.tcl b/dns.tcl deleted file mode 100755 index fa11151..0000000 --- a/dns.tcl +++ /dev/null @@ -1,68 +0,0 @@ -#!/usr/bin/env tclsh - -set auto_path [linsert $auto_path 0 .] - -package require nfsHttp - -namespace eval ::nfs:: { - - variable CFG -#------------------------------CONFIG----------------------------------------------# - set subdomain "" - if {[info exists ::env(SUBDOMAIN)]} { - set subdomain $::env(SUBDOMAIN) - } - - set ip_provider "http://ipinfo.io/ip" - if {[info exists ::env(IP_PROVIDER)]} { - set ip_provider $::env(IP_PROVIDER) - } - - foreach {config value} [list\ - username $::env(USERNAME)\ - api_key $::env(API_KEY)\ - domain $::env(DOMAIN)\ - subdomain $subdomain\ - ip_provider $ip_provider\ - ] {set CFG($config) $value} -#------------------------------END CONFIG------------------------------------------# -} - -proc updateIPs {current_ip domain_ip} { - set now [clock seconds] - # When there's no existing record for a domain name, the - # listRRs API query returns the domain name of the name server. - if {[string first "nearlyfreespeech.net" $domain_ip] ne -1} { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: The domain IP doesn't appear to be set yet." - } else { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: Current IP: $current_ip doesn't match Domain IP: $domain_ip" - } - - # Set or update the domain IP - ::nfs::Http::replaceDomain $current_ip - - # Check to see if the update was successful - set now [clock seconds] - if {[::nfs::Utility::doIPsMatch]} { - set domain_ip [::nfs::Http::fetchDomainIP] - - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: IPs match now! Current IP: $current_ip Domain IP: $domain_ip" - } else { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: They still don't match. Current IP: $current_ip Domain IP: $domain_ip" - } -} - -proc compareIPs {} { - set domain_ip [::nfs::Http::fetchDomainIP] - set current_ip [::nfs::Http::fetchCurrentIP] - set now [clock seconds] - - if {[::nfs::Utility::doIPsMatch]} { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: IPs still match! Current IP: $current_ip Domain IP: $domain_ip" - exit - } else { - updateIPs $current_ip $domain_ip - } -} - -compareIPs diff --git a/packages/http.tcl b/packages/http.tcl deleted file mode 100644 index 4a2e7ac..0000000 --- a/packages/http.tcl +++ /dev/null @@ -1,108 +0,0 @@ -package provide nfsHttp 1.0 - -package require http -package require json -package require sha1 -package require tls - -package require nfsUtility - -namespace eval ::nfs::Http:: { - - namespace export getRequest fetchCurrentIP fetchDomainIP replaceDomain -} - -proc ::nfs::Http::getRequest {uri {body { }}} { - set url "https://api.nearlyfreespeech.net$uri" - set header [createHeader $uri $body] - - http::register https 443 [list ::tls::socket -tls1 1 -autoservername 1] - - try {set token [http::geturl $url -headers $header -query $body]} - - set resp_data [http::data $token] - - ::nfs::Utility::validateResponse $resp_data - - http::cleanup $token - http::unregister https - - return $resp_data -} - -proc ::nfs::Http::fetchCurrentIP {} { - variable ::nfs::CFG - - set token [http::geturl $::nfs::CFG(ip_provider)] - set data [http::data $token] - - http::cleanup $token - - return [string trim $data] -} - -proc ::nfs::Http::fetchDomainIP {} { - variable ::nfs::CFG - - set uri "/dns/$::nfs::CFG(domain)/listRRs" - set body "name=$::nfs::CFG(subdomain)" - set now [clock seconds] - - set data [getRequest $uri $body] - - # Response will be empty if domain is not set - if {$data eq {[]}} { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: No IP address is currently set." - return "UNSET" - } - - set resp_data [json::json2dict $data] - - ::nfs::Utility::validateResponse $resp_data - - set ip [dict get [lindex $resp_data 0] data] - - return $ip -} - -proc ::nfs::Http::replaceDomain {current_ip} { - variable ::nfs::CFG - - set now [clock seconds] - - if {$::nfs::CFG(subdomain) eq ""} { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: Setting $::nfs::CFG(domain) to $current_ip..." - } else { - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: Setting $::nfs::CFG(subdomain).$::nfs::CFG(domain) to $current_ip..." - } - - set uri "/dns/$::nfs::CFG(domain)/replaceRR" - set body "name=$::nfs::CFG(subdomain)&type=A&data=$current_ip" - - set data [getRequest $uri $body] - - if {$data ne {}} { - set resp_data [json::json2dict $data] - - ::nfs::Utility::validateResponse $resp_data - } -} - -proc createHeader {uri body} { - variable ::nfs::CFG - - set timestamp [clock scan now] - set salt [::nfs::Utility::randomRangeString "16"] - - set uts "$::nfs::CFG(username);$timestamp;$salt" - set body_hash [sha1::sha1 $body] - - set msg "$uts;$::nfs::CFG(api_key);$uri;$body_hash" - - set hash [sha1::sha1 $msg] - - set header "$uts;$hash" - - return "X-NFSN-Authentication $header" - -} diff --git a/packages/utility.tcl b/packages/utility.tcl deleted file mode 100644 index 5bd0e38..0000000 --- a/packages/utility.tcl +++ /dev/null @@ -1,38 +0,0 @@ -package provide nfsUtility 1.0 - -package require nfsHttp - -namespace eval ::nfs::Utility:: { - - namespace export randomRangeString doIPsMatch validateResponse -} - -proc ::nfs::Utility::randomRangeString {length} { - set chars "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz123456789" - set range [expr {[string length $chars]-1}] - - set txt "" - for {set i 0} {$i < $length} {incr i} { - set pos [expr {int(rand()*$range)}] - append txt [string range $chars $pos $pos] - } - return $txt -} - -proc ::nfs::Utility::doIPsMatch {} { - set domain_ip [::nfs::Http::fetchDomainIP] - set current_ip [::nfs::Http::fetchCurrentIP] - - return [expr {$domain_ip == $current_ip} ? 1 : 0] -} - -proc ::nfs::Utility::validateResponse {resp} { - if {[dict exists $resp error]} { - set now [clock seconds] - - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: ERROR: [dict get $resp error]" - puts "[clock format $now -format {%y-%m-%d %H:%M:%S}]: ERROR: [dict get $resp debug]" - - exit - } -} diff --git a/pkgIndex.tcl b/pkgIndex.tcl deleted file mode 100644 index 082db10..0000000 --- a/pkgIndex.tcl +++ /dev/null @@ -1,5 +0,0 @@ -package ifneeded nfsUtility 1.0 \ - [list source [file join $dir packages/utility.tcl]] - -package ifneeded nfsHttp 1.0 \ - [list source [file join $dir packages/http.tcl]] \ No newline at end of file From 65f52fbf57f32019160f1e507d70f19a366d4c7b Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:22:18 -0400 Subject: [PATCH 23/41] make the NFSN requests correctly --- nfsn-ddns.py | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index c89c468..feee51c 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -41,10 +41,9 @@ def validateNFSNResponse(response): def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): url = NFSN_API_DOMAIN + path - headers = createNFSNAuthHeader(nfsn_username, nfsn_apikey, url, body) + headers = createNFSNAuthHeader(nfsn_username, nfsn_apikey, path, body) - response = requests.get(url, body=body, headers=headers) - response.raise_for_status() + response = requests.post(url, data=body, headers=headers) data = response.json() validateNFSNResponse(data) @@ -86,16 +85,16 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): validateNFSNResponse(response_data) -def createNFSNAuthHeader(nfsn_username, nfsn_apikey, uri, body) -> dict[str,str]: +def createNFSNAuthHeader(nfsn_username, nfsn_apikey, url_path, body) -> dict[str,str]: salt = randomRangeString(16) timestamp = int(datetime.now(timezone.utc).timestamp()) uts = f"{nfsn_username};{timestamp};{salt}" - body_hash = str(hashlib.sha1(bytes(body, 'utf-8'))) + body_hash = hashlib.sha1(bytes(body, 'utf-8')).hexdigest() - msg = f"{uts};{nfsn_apikey};{uri};{body_hash}" + msg = f"{uts};{nfsn_apikey};{url_path};{body_hash}" - full_hash = str(hashlib.sha1(bytes(msg, 'utf-8'))) + full_hash = hashlib.sha1(bytes(msg, 'utf-8')).hexdigest() return {"X-NFSN-Authentication": f"{uts};{full_hash}"} From e8b25e9c7747acbfb0f47086da0507fa5fc0dd7c Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:46:56 -0400 Subject: [PATCH 24/41] be more tolerant of different stuff when validating NFSN responses --- nfsn-ddns.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index feee51c..d9cf5b7 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -34,6 +34,12 @@ def output(msg, type_msg=None, timestamp=None): def validateNFSNResponse(response): + if response is None: + print("none response received") + try: + response = response[0] + except Exception: + pass if response.get("error") is not None: output(response.get('error'), type_msg="ERROR") output(response.get('debug'), type_msg="ERROR") From 5ba6472dc211a7c99722099683f530c8f32fd1ed Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:47:08 -0400 Subject: [PATCH 25/41] adjust text trimming --- nfsn-ddns.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index d9cf5b7..fb94668 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -59,7 +59,8 @@ def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): def fetchCurrentIP(): response = requests.get(IPV4_PROVIDER_URL) response.raise_for_status() - return response.body().trim() + return response.text.strip() + def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): path = f"/dns/{domain}/listRRs" From 05a48fb07c4aa05c8b44279c65d26f0bc212e70d Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:48:05 -0400 Subject: [PATCH 26/41] use an empty string if no subdomain is specified --- nfsn-ddns.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index fb94668..ef1d4af 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -63,6 +63,7 @@ def fetchCurrentIP(): def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): + subdomain = subdomain or "" path = f"/dns/{domain}/listRRs" body = f"name={subdomain}" @@ -78,8 +79,8 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): path = f"/dns/{domain}/replaceRR" - name = f"name={subdomain}&" if subdomain else "" - body = f"{name}type=A&data={current_ip}" + subdomain = subdomain or "" + body = f"name={subdomain}&type=A&data={current_ip}" if subdomain == "": output(f"Setting {domain} to {current_ip}...") From 242f3ca83e0fa6a38ee296c17d30b27772eb23c1 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:49:06 -0400 Subject: [PATCH 27/41] filter the fetchDomainIP response so it only grabs from the values for the subdomain requested --- nfsn-ddns.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index ef1d4af..b7fbba2 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -69,12 +69,14 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): response_data = makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) - if response_data == []: - output("No IP address is currently set.") - return "UNSET" - return response_data[0].get("data") + data = list(filter(lambda r: r['name'] == subdomain, response_data)) + + if len(data) == 0: + output("No IP address is currently set.") + return + return data[0].get("data") def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): From 398529e16100bd23cc28875c1c9dfefc7b4f4459 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:50:27 -0400 Subject: [PATCH 28/41] ensure theres always a body value when calculating auth --- nfsn-ddns.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index b7fbba2..ec23aea 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -96,10 +96,13 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): def createNFSNAuthHeader(nfsn_username, nfsn_apikey, url_path, body) -> dict[str,str]: + # See https://members.nearlyfreespeech.net/wiki/API/Introduction for how this auth process works + salt = randomRangeString(16) timestamp = int(datetime.now(timezone.utc).timestamp()) uts = f"{nfsn_username};{timestamp};{salt}" - + # "If there is no request body, the SHA1 hash of the empty string must be used." + body = body or "" body_hash = hashlib.sha1(bytes(body, 'utf-8')).hexdigest() msg = f"{uts};{nfsn_apikey};{url_path};{body_hash}" From d411cc83400d35919635d3935d7889a0c1c0f0cb Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:50:42 -0400 Subject: [PATCH 29/41] dont double-validate the data --- nfsn-ddns.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index ec23aea..1685fe3 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -89,10 +89,8 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): else: output(f"Setting {subdomain}.{domain} to {current_ip}...") - response_data = makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) + makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) - if response_data != {}: - validateNFSNResponse(response_data) def createNFSNAuthHeader(nfsn_username, nfsn_apikey, url_path, body) -> dict[str,str]: From b307c9408eb41f98293a0d8a49a5cb4747801a89 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 22:51:15 -0400 Subject: [PATCH 30/41] create a new domain record if one doesn't exist already --- nfsn-ddns.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 1685fe3..49f2158 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -79,8 +79,11 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): return data[0].get("data") -def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey): - path = f"/dns/{domain}/replaceRR" +def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, create=False): + + action = "replaceRR" if not create else "addRR" + + path = f"/dns/{domain}/{action}" subdomain = subdomain or "" body = f"name={subdomain}&type=A&data={current_ip}" @@ -114,18 +117,17 @@ def createNFSNAuthHeader(nfsn_username, nfsn_apikey, url_path, body) -> dict[str def updateIPs(domain, subdomain, domain_ip, current_ip, nfsn_username, nfsn_apikey): # When there's no existing record for a domain name, the # listRRs API query returns the domain name of the name server. - if domain_ip.startswith("nearlyfreespeech.net"): + if domain_ip is not None and domain_ip.startswith("nearlyfreespeech.net"): output("The domain IP doesn't appear to be set yet.") else: - output(f"Current IP: {current_ip} doesn't match Domain IP: {domain_ip}") - - replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey) + output(f"Current IP: {current_ip} doesn't match Domain IP: {domain_ip or 'UNSET'}") + replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, create=domain_ip is None) # Check to see if the update was successful new_domain_ip = fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey) - if doIPsMatch(ip_address(new_domain_ip), ip_address(current_ip)): + if new_domain_ip is not None and doIPsMatch(ip_address(new_domain_ip), ip_address(current_ip)): output(f"IPs match now! Current IP: {current_ip} Domain IP: {domain_ip}") else: output(f"They still don't match. Current IP: {current_ip} Domain IP: {domain_ip}") @@ -151,7 +153,7 @@ def ensure_present(value, name): domain_ip = fetchDomainIP(nfsn_domain, nfsn_subdomain, nfsn_username, nfsn_apikey) current_ip = fetchCurrentIP() - if doIPsMatch(ip_address(domain_ip), ip_address(current_ip)): + if domain_ip is not None and doIPsMatch(ip_address(domain_ip), ip_address(current_ip)): output(f"IPs still match! Current IP: {current_ip} Domain IP: {domain_ip}") else: updateIPs(nfsn_domain, nfsn_subdomain, domain_ip, current_ip, nfsn_username, nfsn_apikey) From b20695bb350499febedc517fea5492379a422177 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 23:11:01 -0400 Subject: [PATCH 31/41] set the content type so NFSN sees we are actually passing the correct values --- nfsn-ddns.py | 1 + 1 file changed, 1 insertion(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 49f2158..747d29d 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -48,6 +48,7 @@ def validateNFSNResponse(response): def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): url = NFSN_API_DOMAIN + path headers = createNFSNAuthHeader(nfsn_username, nfsn_apikey, path, body) + headers["Content-Type"] = "application/x-www-form-urlencoded" response = requests.post(url, data=body, headers=headers) From 2908b86c7cea92953b9e0c4eb0634e9c88388046 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 23:11:14 -0400 Subject: [PATCH 32/41] check for and handle empty responses --- nfsn-ddns.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 747d29d..f8b7689 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -51,8 +51,11 @@ def makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey): headers["Content-Type"] = "application/x-www-form-urlencoded" response = requests.post(url, data=body, headers=headers) - - data = response.json() + # response.raise_for_status() + if response.text != "": + data = response.json() + else: + data = "" validateNFSNResponse(data) return data From 89059cadb94b1785642ea295cf1aab4e179dd72a Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 23:11:57 -0400 Subject: [PATCH 33/41] use a proper library to url encode the body requests can do this for us, but we need access to the result for NFSNs bespoke hashing and authentication thingy --- nfsn-ddns.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index f8b7689..d69e94a 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -1,3 +1,4 @@ +from urllib.parse import urlencode import requests import os from ipaddress import IPv4Address, IPv6Address, ip_address @@ -69,7 +70,11 @@ def fetchCurrentIP(): def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): subdomain = subdomain or "" path = f"/dns/{domain}/listRRs" - body = f"name={subdomain}" + body = { + "name": subdomain, + "type": "A" + } + body = urlencode(body) response_data = makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) @@ -89,7 +94,12 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, cre path = f"/dns/{domain}/{action}" subdomain = subdomain or "" - body = f"name={subdomain}&type=A&data={current_ip}" + body = { + "name": subdomain, + "type": "A", + "data": current_ip + } + body = urlencode(body) if subdomain == "": output(f"Setting {domain} to {current_ip}...") From bbfdd6a09f00af58d4bdd6bba833a5c72cfe37b9 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 23:12:44 -0400 Subject: [PATCH 34/41] pass in TTL cuz someone may want to customize it later --- nfsn-ddns.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index d69e94a..905f65c 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -88,7 +88,7 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): return data[0].get("data") -def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, create=False): +def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, create=False, ttl=3600): action = "replaceRR" if not create else "addRR" @@ -97,7 +97,8 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, cre body = { "name": subdomain, "type": "A", - "data": current_ip + "data": current_ip, + "ttl": ttl } body = urlencode(body) From a4436ed3af1f92180733cd073926555aa7f854c2 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 23:13:00 -0400 Subject: [PATCH 35/41] some cleanup of whitespace --- nfsn-ddns.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 905f65c..dc1663a 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -78,13 +78,12 @@ def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey): response_data = makeNFSNHTTPRequest(path, body, nfsn_username, nfsn_apikey) - - data = list(filter(lambda r: r['name'] == subdomain, response_data)) if len(data) == 0: output("No IP address is currently set.") return + return data[0].get("data") From 5202d53695aa7d8e9ec390bdcc050234f1f6fba3 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 23:13:21 -0400 Subject: [PATCH 36/41] check for and exit early on more types of data when validating an NFSN response --- nfsn-ddns.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index dc1663a..998aa02 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -37,6 +37,14 @@ def output(msg, type_msg=None, timestamp=None): def validateNFSNResponse(response): if response is None: print("none response received") + return + elif response == "": + print("empty string received") + return + elif response == []: + print("empty list received") + return + try: response = response[0] except Exception: From c6526c1fa677e84149b88493a19f851e90ea2437 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Sun, 9 Jun 2024 21:35:18 -0400 Subject: [PATCH 37/41] first pass at new dockerfile --- Dockerfile | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/Dockerfile b/Dockerfile index c3b39f6..d008772 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,21 +1,16 @@ -FROM alpine:latest +FROM python:3.8-alpine +# FROM alpine -COPY *.tcl /root/ -COPY packages/* /root/packages/ +RUN pip3 install requests + +COPY *.py /root/ COPY LICENSE /root/LICENSE COPY README.md /root/README.md -RUN apk add tcl tcl-tls - -RUN echo '@community http://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && \ - echo '@edge http://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && \ - apk add --upgrade --no-cache --update ca-certificates wget git curl openssh tar gzip apk-tools@edge && \ - apk upgrade --update --no-cache - -# https://github.com/gjrtimmer/docker-alpine-tcl/blob/master/Dockerfile#L38 -RUN curl -sSL https://github.com/tcltk/tcllib/archive/release.tar.gz | tar -xz -C /tmp && \ - tclsh /tmp/tcllib-release/installer.tcl -no-html -no-nroff -no-examples -no-gui -no-apps -no-wait -pkg-path /usr/lib/tcllib && \ - rm -rf /tmp/tcllib* +# RUN echo '@community http://dl-cdn.alpinelinux.org/alpine/edge/community' >> /etc/apk/repositories && \ +# echo '@edge http://dl-cdn.alpinelinux.org/alpine/edge/main' >> /etc/apk/repositories && \ +# apk add --upgrade --no-cache --update ca-certificates wget git curl openssh tar gzip python3 apk-tools@edge && \ +# apk upgrade --update --no-cache RUN mkdir /logs From bad6439ca650ec73edda4122c80b4d578d3b9356 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Mon, 10 Jun 2024 00:09:58 -0400 Subject: [PATCH 38/41] fix a typing issue that python 3.8 complained about --- nfsn-ddns.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nfsn-ddns.py b/nfsn-ddns.py index 998aa02..f4059a9 100644 --- a/nfsn-ddns.py +++ b/nfsn-ddns.py @@ -2,7 +2,7 @@ import requests import os from ipaddress import IPv4Address, IPv6Address, ip_address -from typing import Union, NewType +from typing import Union, NewType, Dict import random import string from datetime import datetime, timezone @@ -118,7 +118,7 @@ def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, cre -def createNFSNAuthHeader(nfsn_username, nfsn_apikey, url_path, body) -> dict[str,str]: +def createNFSNAuthHeader(nfsn_username, nfsn_apikey, url_path, body) -> Dict[str,str]: # See https://members.nearlyfreespeech.net/wiki/API/Introduction for how this auth process works salt = randomRangeString(16) From 10eed39ed77df61a69ba6ee87bbd9d61a248f825 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Mon, 10 Jun 2024 00:10:59 -0400 Subject: [PATCH 39/41] install python dependencies --- Dockerfile | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index d008772..448dfec 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,9 @@ FROM python:3.8-alpine # FROM alpine -RUN pip3 install requests +RUN pip install --upgrade pip + +RUN pip3 install requests python-dotenv COPY *.py /root/ COPY LICENSE /root/LICENSE From f169e17f916f3e0ce752bcb2890b4ba7f60ec882 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Mon, 10 Jun 2024 00:15:29 -0400 Subject: [PATCH 40/41] update cron run command --- Dockerfile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile b/Dockerfile index 448dfec..b995598 100644 --- a/Dockerfile +++ b/Dockerfile @@ -19,6 +19,6 @@ RUN mkdir /logs WORKDIR /root ARG CRON_SCHEDULE="*/30 * * * *" -RUN echo "$(crontab -l 2>&1; echo "${CRON_SCHEDULE} /root/dns.tcl")" | crontab - +RUN echo "$(crontab -l 2>&1; echo "${CRON_SCHEDULE} python3 /root/nfsn-ddns.py")" | crontab - CMD ["crond", "-f", "2>&1"] From 01dbfd404cacd37c96f83a9b4b29bafc87b4acd6 Mon Sep 17 00:00:00 2001 From: Adrian Edwards Date: Mon, 10 Jun 2024 00:34:35 -0400 Subject: [PATCH 41/41] update README to remove TCL references --- README.md | 27 ++++++++++++++++----------- requirements.txt | 2 ++ 2 files changed, 18 insertions(+), 11 deletions(-) create mode 100644 requirements.txt diff --git a/README.md b/README.md index 80794be..f99003a 100644 --- a/README.md +++ b/README.md @@ -9,8 +9,10 @@ IP address of the server, and then compares the two. If the public IP address is the domain/subdomain with the new IP address. ## Requirements -[Tcl](http://www.tcl.tk/software/tcltk), [Tcllib](http://www.tcl.tk/software/tcllib), and [TclTLS](https://core.tcl-lang.org/tcltls/index) are the only requirements. -They come pre-installed or are easily installed on most *nix operating systems. +- `python-dotenv` +- `requests` + +Both can be downloaded from pip using `pip install -r requirements.txt` ## Configuring Configurations are set by providing the script with environment variables @@ -26,13 +28,20 @@ Configurations are set by providing the script with environment variables ## Running ### Manually -It is as easy as running: `tclsh dns.tcl` - -or make it executable with `chmod u+x dns.tcl` and then run `./dns.tcl` +It is as easy as running: `python3 ./nfsn-ddns.py` (after installing the dependencies listed above) To include all of the environmental variables inline when running, you can do something like this: ```bash -$ export USERNAME=username API_KEY=api_key DOMAIN=domain.com SUBDOMAIN=subdomain && ./dns.tcl +$ export USERNAME=username API_KEY=api_key DOMAIN=domain.com SUBDOMAIN=subdomain && python3 ./nfsn-ddns.py +``` + +or you can put your variables in a `.env` file: + +``` +API_KEY= +USERNAME= +DOMAIN= +SUBDOMAIN= ``` ### With Docker @@ -65,15 +74,11 @@ services: To run the container locally (and let it run its cronjobs), use this command: `docker run -it --rm --init nfs-dynamic-dns` -to run the container locally and be put into a shell where you can run `./dns.tcl` yourself use this: +to run the container locally and be put into a shell where you can run `python3 ./nfsn-ddns.py` yourself use this: `docker run -it --rm --init nfs-dynamic-dns sh` If your setup uses environment variables, you will also need to add the `--env-file` argument (or specify variables individually with [the `-e` docker flag](https://docs.docker.com/engine/reference/run/#env-environment-variables)). The `--env-file` option is for [docker run](https://docs.docker.com/engine/reference/commandline/run/) and the env file format can be found [here](https://docs.docker.com/compose/env-file/). -## Scheduling -It can be setup to run as a cron job to completely automate this process. Something such as: -> @hourly /usr/local/bin/tclsh /scripts/nfs-dynamic-dns/dns.tcl - ### Docker When using the Docker file, it's by default scheduled to run every 30 minutes. However, this is configurable when building the container. The `CRON_SCHEDULE` [build arg](https://docs.docker.com/engine/reference/builder/#arg) can be overriden. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..323ef8c --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +python-dotenv +requests \ No newline at end of file