Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
.claude/
.env*
.venv/
31 changes: 22 additions & 9 deletions Dockerfile
Original file line number Diff line number Diff line change
@@ -1,24 +1,37 @@
FROM python:3.8-alpine
# FROM alpine

# Install dependencies
RUN pip install --upgrade pip

RUN pip3 install requests python-dotenv
RUN apk add --no-cache curl socat bash openssl

# Copy scripts and docs
COPY *.py /root/
COPY entrypoint.sh /root/entrypoint.sh
COPY LICENSE /root/LICENSE
COPY README.md /root/README.md

# 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 chmod +x /root/entrypoint.sh

# Install acme.sh
RUN curl -sSL https://get.acme.sh | sh

RUN mkdir /logs
# Install NFSN DNS plugin for acme.sh
COPY dns_nfsn.sh /root/.acme.sh/dnsapi/dns_nfsn.sh
RUN chmod +x /root/.acme.sh/dnsapi/dns_nfsn.sh

# Directories for logs and certs
RUN mkdir -p /logs /certs && chmod 777 /logs

WORKDIR /root

# Set cron schedules (with backwards compatibility)
ARG CRON_SCHEDULE="*/30 * * * *"
RUN echo "$(crontab -l 2>&1; echo "${CRON_SCHEDULE} python3 /root/nfsn-ddns.py")" | crontab -
ARG DDNS_CRON="${CRON_SCHEDULE}"
ARG CERT_CRON="0 3 * * *"

# Convert to ENV for runtime use in entrypoint.sh
ENV DDNS_CRON="${DDNS_CRON}"
ENV CERT_CRON="${CERT_CRON}"

CMD ["crond", "-f", "2>&1"]
ENTRYPOINT ["/root/entrypoint.sh"]
145 changes: 135 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,10 @@ Configurations are set by providing the script with environment variables or com
| API_KEY | | Y | API key for using NFSN's APIs. This can be obtained via the Member Interface > "Profile" tab > "Actions" > "Manage API Key" |
| DOMAIN | | Y | Domain that the subdomain belongs to |
| SUBDOMAIN | | N | Subdomain to update with the script. Leave blank for the bare domain name |
| ENABLE_DDNS | | N | Enable dynamic DNS updates. Defaults to `true` for backwards compatibility. Set to `false` to disable DDNS and run only certificate management. |
| ENABLE_CERTS | | N | Enable Let's Encrypt certificate issuing and renewal via ACME DNS-01 challenge. Defaults to `false`. Set to `true` to enable certificate management. |
| DDNS_CRON | | N | Cron schedule for DDNS updates. Defaults to `*/30 * * * *` (every 30 minutes). Only used if `ENABLE_DDNS=true`. Can also be set at build time via `--build-arg`. |
| CERT_CRON | | N | Cron schedule for certificate renewal checks. Defaults to `0 3 * * *` (daily at 3 AM). Only used if `ENABLE_CERTS=true`. Can also be set at build time via `--build-arg`. |
| IP_PROVIDER | | N | Use a different IP providing service than the default: [http://ipinfo.io/ip](http://ipinfo.io/ip) This might be useful if the default provider is unavailable or is blocked. The alternate provider MUST be served over `http` (please open an issue if this is ever a problem) and MUST return ONLY the IP in the response body |
| IPV6_PROVIDER | | N | Use a different IP providing service than the default: [http://v6.ipinfo.io/ip](http://v6.ipinfo.io/ip) This might be useful if the default provider is unavailable or is blocked. The alternate provider MUST be served over `http` (please open an issue if this is ever a problem) and MUST return ONLY the IP in the response body |
| ENABLE_IPV6 | `--ipv6` or `-6` | N | Set this to any value to also cause the script to check for and update AAAA records on the specified domain. |
Expand All @@ -46,10 +50,26 @@ $ export USERNAME=username API_KEY=api_key DOMAIN=domain.com SUBDOMAIN=subdomain
or you can put your variables in a `.env` file:

```
API_KEY=
USERNAME=
DOMAIN=
SUBDOMAIN=
# NFSN credentials
USERNAME=your_username
API_KEY=your_api_key
DOMAIN=example.com
SUBDOMAIN=subdomain

# Feature flags
ENABLE_DDNS=true # Enable dynamic DNS (default: true)
ENABLE_CERTS=false # Enable Let's Encrypt certs (default: false)

# Optional: Customize schedules
#DDNS_CRON=*/30 * * * * # Every 30 minutes (default)
#CERT_CRON=0 3 * * * # Daily at 3 AM (default)

# Optional: IPv6 support
#ENABLE_IPV6=true

# Optional: Customize IP providers
#IP_PROVIDER=http://ipinfo.io/ip
#IPV6_PROVIDER=http://v6.ipinfo.io/ip
```

### With Docker
Expand Down Expand Up @@ -85,25 +105,130 @@ to run the container locally and be put into a shell where you can run `python3
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/).

### 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.
When using the Docker file, DDNS updates are scheduled to run every 30 minutes by default. This is configurable when building the
container using [build args](https://docs.docker.com/engine/reference/builder/#arg).

With docker, the build step (step 2) can be done like this:
#### Customizing Cron Schedules

`$ docker build --build-arg CRON_SCHEDULE="*/5 * * * *" -t nfs-dynamic-dns .`
You can customize the schedules using build args:

With docker compose, it can be done like this:
**With docker:**
```bash
# For DDNS updates (backwards compatible)
$ docker build --build-arg CRON_SCHEDULE="*/5 * * * *" -t nfs-dynamic-dns .

# Or use the new arg names
$ docker build --build-arg DDNS_CRON="*/5 * * * *" --build-arg CERT_CRON="0 2 * * *" -t nfs-dynamic-dns .
```

**With docker compose:**
```yaml
services:
nfs-dynamic-dns:
image: nfs-dynamic-dns
build:
context: ./nfs-dynamic-dns
args:
- CRON_SCHEDULE=*/5 * * * *
- DDNS_CRON=*/5 * * * *
- CERT_CRON=0 2 * * *
container_name: nfs-dynamic-dns
...
```

**Note:** The `CRON_SCHEDULE` build arg is still supported for backwards compatibility and maps to `DDNS_CRON`.

## ACME Certificate Management

This application can automatically issue and renew Let's Encrypt certificates using DNS-01 challenges via the NFSN DNS API.

### Requirements for Certificate Management
- Set `ENABLE_CERTS=true` environment variable
- Ensure `DOMAIN` environment variable is set (certificates will be issued for `$DOMAIN` and `*.$DOMAIN`)
- Mount a volume to `/certs` to persist certificates outside the container

### Certificate Paths
Certificates are stored in `/certs`:
- Certificate: `/certs/$DOMAIN.crt`
- Private key: `/certs/$DOMAIN.key`

### Docker Example with Certificates
```bash
docker run -d \
--name nfsn-dynamic-dns \
--env-file .env \
-v /path/to/certs:/certs \
nfs-dynamic-dns
```

Make sure your `.env` file includes:
```
ENABLE_CERTS=true
```

### Docker Compose Example with Certificates
```yaml
version: "3"

services:
nfs-dynamic-dns:
image: nfs-dynamic-dns
build: ./nfs-dynamic-dns
container_name: nfs-dynamic-dns
network_mode: host
environment:
- USERNAME=username
- API_KEY=api_key
- DOMAIN=domain.com
- SUBDOMAIN=subdomain
- ENABLE_DDNS=true
- ENABLE_CERTS=true
volumes:
- ./certs:/certs
- ./logs:/logs
restart: unless-stopped
```

### Running Only Certificate Management (No DDNS)
To run only certificate issuing/renewal without DDNS:
```bash
docker run -d \
--name nfsn-certs \
-e USERNAME=username \
-e API_KEY=api_key \
-e DOMAIN=domain.com \
-e ENABLE_DDNS=false \
-e ENABLE_CERTS=true \
-v /path/to/certs:/certs \
nfs-dynamic-dns
```

### Manual Certificate Operations
To manually issue a certificate inside the container:
```bash
docker exec nfsn-dynamic-dns /root/.acme.sh/acme.sh --issue \
-d domain.com -d "*.domain.com" \
--dns dns_nfsn \
--cert-file /certs/domain.com.crt \
--key-file /certs/domain.com.key \
--home /root/.acme.sh
```

To force renewal:
```bash
docker exec nfsn-dynamic-dns /root/.acme.sh/acme.sh --renew \
-d domain.com \
--force \
--home /root/.acme.sh
```

### Customizing Certificate Renewal Schedule
Both DDNS and certificate renewal schedules can be customized via environment variables:

```yaml
environment:
- DDNS_CRON=*/15 * * * * # Check DDNS every 15 minutes
- CERT_CRON=0 2 * * * # Check renewal daily at 2 AM
```

## Troubleshooting
The script communicates with NearlyFreeSpeech.NET via its RESTful API. Specifics about the API can be found [here](https://members.nearlyfreespeech.net/wiki/API/Introduction).
35 changes: 35 additions & 0 deletions dns_nfsn.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#!/bin/bash

# NFSN DNS API plugin for acme.sh
# Requires: USERNAME, API_KEY, DOMAIN environment variables

dns_nfsn_add() {
fulldomain=$1
txtvalue=$2

# Extract subdomain from fulldomain
# For _acme-challenge.example.com, we want _acme-challenge
# For _acme-challenge.*.example.com, we want _acme-challenge.*
subdomain="${fulldomain%.$DOMAIN}"

echo "[ACME] Adding DNS record: $subdomain = $txtvalue"
python3 /root/nfsn-acme.py auth "$txtvalue" "$subdomain"

# Wait for DNS propagation
sleep 30

return 0
}

dns_nfsn_rm() {
fulldomain=$1
txtvalue=$2

# Extract subdomain from fulldomain
subdomain="${fulldomain%.$DOMAIN}"

echo "[ACME] Removing DNS record: $subdomain"
python3 /root/nfsn-acme.py cleanup "$subdomain"

return 0
}
69 changes: 69 additions & 0 deletions entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
#!/bin/bash
set -e

# Load environment variables from .env
if [ -f /root/.env ]; then
export $(grep -v '^#' /root/.env | xargs)
fi

# Set defaults (backwards compatibility)
ENABLE_DDNS="${ENABLE_DDNS:-true}"
ENABLE_CERTS="${ENABLE_CERTS:-false}"
DDNS_CRON="${DDNS_CRON:-*/30 * * * *}"
CERT_CRON="${CERT_CRON:-0 3 * * *}"

CERT_HOME="/root/.acme.sh"
CERT_PATH="/certs"

# Clear existing crontab
crontab -r 2>/dev/null || true

# Conditional DDNS cron setup
if [ "$ENABLE_DDNS" = "true" ]; then
echo "[INIT] Setting up DDNS cron: $DDNS_CRON"
echo "$DDNS_CRON python3 /root/nfsn-ddns.py >> /logs/ddns.log 2>&1" >> /tmp/crontab.tmp
else
echo "[INIT] DDNS disabled"
fi

# Conditional ACME setup
if [ "$ENABLE_CERTS" = "true" ]; then
echo "[INIT] Certificate management enabled"
mkdir -p "$CERT_PATH"

# Issue initial certificate if needed
DOMAIN_CERT_FILE="$CERT_PATH/$DOMAIN.crt"
DOMAIN_KEY_FILE="$CERT_PATH/$DOMAIN.key"

if [ ! -f "$DOMAIN_CERT_FILE" ] || [ ! -f "$DOMAIN_KEY_FILE" ]; then
echo "[INIT] Issuing certificate for $DOMAIN"
$CERT_HOME/acme.sh --issue \
-d "$DOMAIN" -d "*.$DOMAIN" \
--dns dns_nfsn \
--server letsencrypt \
--cert-file "$DOMAIN_CERT_FILE" \
--key-file "$DOMAIN_KEY_FILE" \
--home "$CERT_HOME" || echo "[ERROR] Cert issuing failed, will retry via cron"
else
echo "[INIT] Certificate exists for $DOMAIN"
fi

# Add renewal cron
echo "[INIT] Setting up ACME renewal cron: $CERT_CRON"
echo "$CERT_CRON $CERT_HOME/acme.sh --renew --home $CERT_HOME --cron >> /logs/acme.log 2>&1" >> /tmp/crontab.tmp
else
echo "[INIT] Certificate management disabled"
fi

# Install the final crontab
crontab /tmp/crontab.tmp
rm /tmp/crontab.tmp

# If arguments passed, execute them; otherwise start cron
if [ $# -gt 0 ]; then
echo "[INIT] Executing: $@"
exec "$@"
else
echo "[INIT] Starting crond in foreground"
exec crond -f -d 8
fi
41 changes: 41 additions & 0 deletions nfsn-acme.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
import os
import sys

from nfsn_api import fetchDNSRecordData, addDNSRecord, removeDNSRecord

def fetchDomainValue(name, domain, nfsn_username, nfsn_apikey):
return fetchDNSRecordData(name, domain, "TXT", nfsn_username, nfsn_apikey)

def createTxtRecord(name, value, domain, nfsn_username, nfsn_apikey):
addDNSRecord(domain, name, "TXT", value, 300, nfsn_username, nfsn_apikey)
print(f"[ACME] Created TXT record {name}={value}")

def deleteTxtRecord(name, domain, nfsn_username, nfsn_apikey):
value = fetchDomainValue(name, domain, nfsn_username, nfsn_apikey)

if value is None:
print(f"[ACME] No TXT record found for {name}, skipping deletion")
return

removeDNSRecord(domain, name, "TXT", value, nfsn_username, nfsn_apikey)
print(f"[ACME] Deleted TXT record {name}")

if __name__ == "__main__":
nfsn_username = os.getenv('USERNAME')
nfsn_apikey = os.getenv('API_KEY')
nfsn_domain = os.getenv('DOMAIN')

if not nfsn_username or not nfsn_apikey or not nfsn_domain:
print("Missing required environment variables (NFSN_USER, NFSN_API_KEY, DOMAIN)")
sys.exit(1)

if len(sys.argv) < 2:
sys.exit(0)
command = sys.argv[1]
if command == "auth":
token = sys.argv[2]
subdomain = sys.argv[3]
createTxtRecord(subdomain, token, nfsn_domain, nfsn_username, nfsn_apikey)
elif command == "cleanup":
subdomain = sys.argv[2]
deleteTxtRecord(subdomain, nfsn_domain, nfsn_username, nfsn_apikey)
Loading