Skip to content

Commit

Permalink
Merge pull request #16 from MoralCode/python
Browse files Browse the repository at this point in the history
Rewrite in python
  • Loading branch information
mhum authored Jun 16, 2024
2 parents 973e93d + 01dbfd4 commit bdf1ec7
Show file tree
Hide file tree
Showing 8 changed files with 211 additions and 245 deletions.
27 changes: 12 additions & 15 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
FROM alpine:latest
FROM python:3.8-alpine
# FROM alpine

COPY *.tcl /root/
COPY packages/* /root/packages/
COPY LICENSE /root/LICENSE
COPY README.md /root/README.md
RUN pip install --upgrade pip

RUN apk add tcl tcl-tls
RUN pip3 install requests python-dotenv

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
COPY *.py /root/
COPY LICENSE /root/LICENSE
COPY README.md /root/README.md

# 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

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"]
27 changes: 16 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
Expand Down
68 changes: 0 additions & 68 deletions dns.tcl

This file was deleted.

181 changes: 181 additions & 0 deletions nfsn-ddns.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
from urllib.parse import urlencode
import requests
import os
from ipaddress import IPv4Address, IPv6Address, ip_address
from typing import Union, NewType, Dict
import random
import string
from datetime import datetime, timezone
import hashlib

IPAddress = NewType("IPAddress", Union[IPv4Address, IPv6Address])


IPV4_PROVIDER_URL = os.getenv('IP_PROVIDER', "http://ipinfo.io/ip")
IPV6_PROVIDER_URL = 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
random_values = [random.choice(character_options) for _ in range(length)]
return ''.join(random_values)


def doIPsMatch(ip1:IPAddress, ip2:IPAddress) -> bool:
return ip1 == ip2


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}")


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:
pass
if response.get("error") is not None:
output(response.get('error'), type_msg="ERROR")
output(response.get('debug'), type_msg="ERROR")


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)
# response.raise_for_status()
if response.text != "":
data = response.json()
else:
data = ""
validateNFSNResponse(data)

return data

def fetchCurrentIP():
response = requests.get(IPV4_PROVIDER_URL)
response.raise_for_status()
return response.text.strip()


def fetchDomainIP(domain, subdomain, nfsn_username, nfsn_apikey):
subdomain = subdomain or ""
path = f"/dns/{domain}/listRRs"
body = {
"name": subdomain,
"type": "A"
}
body = urlencode(body)

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")


def replaceDomain(domain, subdomain, current_ip, nfsn_username, nfsn_apikey, create=False, ttl=3600):

action = "replaceRR" if not create else "addRR"

path = f"/dns/{domain}/{action}"
subdomain = subdomain or ""
body = {
"name": subdomain,
"type": "A",
"data": current_ip,
"ttl": ttl
}
body = urlencode(body)

if subdomain == "":
output(f"Setting {domain} to {current_ip}...")
else:
output(f"Setting {subdomain}.{domain} to {current_ip}...")

makeNFSNHTTPRequest(path, body, 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}"

full_hash = hashlib.sha1(bytes(msg, 'utf-8')).hexdigest()

return {"X-NFSN-Authentication": f"{uts};{full_hash}"}



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 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 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 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}")


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__":
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 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)
Loading

0 comments on commit bdf1ec7

Please sign in to comment.