From f961bc6c5a8be65ab34b495e11d3f997cb73f0c3 Mon Sep 17 00:00:00 2001 From: KacperLa Date: Mon, 24 Nov 2025 13:17:00 -0500 Subject: [PATCH] First Commit --- .gitignore | 3 + README.md | 213 +++++++++++++++++------- install.sh | 185 +++++++++++++++++++++ logs.sh | 204 +++++++++++++++++++++++ porkbun-ddns.service | 31 ++++ porkbun-ddns.timer | 17 ++ uninstall.sh | 149 +++++++++++++++++ updateDNS.sh | 382 +++++++++++++++++++++++++++++++++++++++++++ 8 files changed, 1123 insertions(+), 61 deletions(-) create mode 100644 .gitignore create mode 100755 install.sh create mode 100755 logs.sh create mode 100644 porkbun-ddns.service create mode 100644 porkbun-ddns.timer create mode 100755 uninstall.sh create mode 100755 updateDNS.sh diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4dc972f --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +# Ignore environment files containing secrets +.env +*.env diff --git a/README.md b/README.md index 3985c34..59edf3f 100644 --- a/README.md +++ b/README.md @@ -41,9 +41,8 @@ SECRET_KEY=sk1_your_secret_key_here # Required: The domain to update DOMAIN=example.com -# Required for multi-machine setup: Unique identifier for this machine -# Each machine should have a different MACHINE_ID -MACHINE_ID=server1 +# Required: Name of the machine for tracking +MACHINE_ID="exmample_machine" ``` ### Getting Porkbun API Credentials @@ -149,24 +148,6 @@ This means: - If you ever want to manually take control of a record that was created by this script, you can remove or change the `ddns:` note and the script will stop managing it. -### Automated Updates with Cron - -To automatically update DNS every 5 minutes: - -```bash -crontab -e -``` - -Add this line (adjust path as needed): -```cron -*/5 * * * * /home/user/porkbun/updateDNS.sh >> /var/log/porkbun-ddns.log 2>&1 -``` - -Or every hour: -```cron -0 * * * * /home/user/porkbun/updateDNS.sh >> /var/log/porkbun-ddns.log 2>&1 -``` - ## Dry-run mode You can run the script in **dry-run** mode to see what it would do without actually @@ -188,6 +169,135 @@ In dry-run mode: This is useful when first configuring the script or making changes to your DNS setup, so you can verify behavior before applying it. +## Systemd integration helpers + +This repository also includes helper scripts to install the updater as a systemd service, +uninstall it cleanly, and view logs more easily. + +### `install.sh` – install systemd timer + service + +`install.sh` sets up a `porkbun-ddns` systemd service and timer that will run +`updateDNS.sh` for you on a schedule (default: every 5 minutes). + +What it does: + +- Verifies required files exist in the current directory: + - `updateDNS.sh` + - `.env` + - `porkbun-ddns.service` + - `porkbun-ddns.timer` +- Ensures `updateDNS.sh` is executable. +- Checks for required dependencies: `curl`, `jq`, `systemctl`. +- Validates that `.env` contains `API_KEY`, `SECRET_KEY`, `DOMAIN`, and `MACHINE_ID`. +- Runs `updateDNS.sh` once as a test (with your current config). +- Installs and enables: + - `/etc/systemd/system/porkbun-ddns.service` + - `/etc/systemd/system/porkbun-ddns.timer` +- Starts the timer and shows its status. + +Usage: + +```bash +cd /path/to/porkbun +./install.sh +``` + +Notes: + +- Do **not** run `install.sh` directly as root; it will use `sudo` when needed. +- The service runs as the current user (it rewrites `User=` and `Group=` in + the service file to match who ran `install.sh`). + +After installation, some useful commands: + +```bash +systemctl status porkbun-ddns.timer +systemctl status porkbun-ddns.service +journalctl -u porkbun-ddns.service -n 50 +``` + +### `uninstall.sh` – remove systemd timer + service + +`uninstall.sh` cleanly removes the `porkbun-ddns` systemd units while leaving your +scripts and configuration files intact. + +What it does: + +- Stops `porkbun-ddns.timer` and `porkbun-ddns.service` if they are running. +- Disables the timer so it no longer starts at boot. +- Removes `/etc/systemd/system/porkbun-ddns.service` and + `/etc/systemd/system/porkbun-ddns.timer`. +- Reloads systemd and resets any failed state for these units. + +Usage: + +```bash +cd /path/to/porkbun +./uninstall.sh +``` + +Notes: + +- Do **not** run `uninstall.sh` as root; it will use `sudo` for systemd operations. +- Your local files remain: + - `updateDNS.sh` + - `.env` + - `porkbun-ddns.service` + - `porkbun-ddns.timer` + so you can reinstall later with `./install.sh`. + +### `logs.sh` – convenient log viewer + +`logs.sh` is a small wrapper around `journalctl` and `systemctl` to make it easier +to inspect the `porkbun-ddns.service` logs and status. + +Basic usage (show last 50 lines): + +```bash +cd /path/to/porkbun +./logs.sh +``` + +Common options: + +- Follow logs in real-time (like `tail -f`): + + ```bash + ./logs.sh -f + ``` + +- Show last N lines: + + ```bash + ./logs.sh -n 100 + ``` + +- Show logs from today only: + + ```bash + ./logs.sh -t + ``` + +- Show logs from the last hour: + + ```bash + ./logs.sh -h + ``` + +- Show only error messages: + + ```bash + ./logs.sh -e + ``` + +- Show service and timer status plus recent runs: + + ```bash + ./logs.sh -s + ``` + +Run `./logs.sh --help` for the full list of options. + ## How It Works 1. **Load Configuration** - Reads API credentials and settings from `.env` @@ -195,13 +305,12 @@ so you can verify behavior before applying it. 3. **Retrieve DNS Records** - Gets all existing DNS records for the domain and displays them 4. **Check for Duplicates** - Detects if multiple records exist for this machine ID 5. **Skip if Unchanged** - If IP matches and exactly one record exists, skips update -6. **Clean Old Records** - Deletes A/ALIAS/CNAME records with no machine ID (migration cleanup) -7. **Delete Own Records** - Removes this machine's previous A records (by matching notes field) -8. **Verify Cleanup** - Ensures all old records were deleted before creating new ones -9. **Create New Record** - Adds fresh A record with current IP and machine ID in notes -10. **Verify Creation** - Confirms exactly one record exists for this machine +6. **Delete Own Records** - Removes this machine's previous A records (by matching notes field) +7. **Verify Cleanup** - Ensures all old records were deleted before creating new ones +8. **Create New Record** - Adds fresh A record with current IP and machine ID in notes +9. **Verify Creation** - Confirms exactly one record exists for this machine -> Note: The script now only deletes and verifies records that it manages +> Note: The script only deletes and verifies records that it manages > (those whose notes are exactly `ddns:`). Other records in the > same zone are left untouched. @@ -228,18 +337,9 @@ so you can verify behavior before applying it. 1. **Immediately revoke** the API keys in your Porkbun account 2. **Generate new keys** and update your `.env` file -3. **Remove secrets from git history** if they were committed: - ```bash - git filter-branch --force --index-filter \ - "git rm --cached --ignore-unmatch .env" \ - --prune-empty --tag-name-filter cat -- --all - ``` ## Troubleshooting -### Error: "dig: command not found" -The script now uses `curl` instead of `dig`, so this shouldn't happen. If you see this, update to the latest version. - ### Error: "API access not enabled" 1. Log in to Porkbun 2. Go to your domain settings @@ -258,7 +358,7 @@ The script now uses `curl` instead of `dig`, so this shouldn't happen. If you se - Check DNS records in Porkbun dashboard to see notes field (look for `ddns:`) ### IP not updating -- Check script is running (add to cron for automatic updates) +- Check script is running - Verify your public IP actually changed - Check Porkbun API status - Review logs for error messages @@ -278,23 +378,23 @@ Example output with the new structured logging format: Mon Nov 24 12:04:38 PM EST 2025 [INFO] Using environment file: /home/user/porkbun/.env IP source: Porkbun API (/ping) Mon Nov 24 12:04:38 PM EST 2025 [INFO] Current Public IP: 45.73.134.145 -Mon Nov 24 12:04:38 PM EST 2025 [INFO] Machine ID: KACPER_NIX -Mon Nov 24 12:04:38 PM EST 2025 [INFO] Domain: aksal.cloud +Mon Nov 24 12:04:38 PM EST 2025 [INFO] Machine ID: EXAMPLE_MACHINE +Mon Nov 24 12:04:38 PM EST 2025 [INFO] Domain: example.com -Mon Nov 24 12:04:38 PM EST 2025 [INFO] Processing DNS record for: aksal.cloud (IP: 45.73.134.145) -Mon Nov 24 12:04:38 PM EST 2025 [INFO] Machine ID: KACPER_NIX -Mon Nov 24 12:04:39 PM EST 2025 [INFO] Existing records for FQDN 'aksal.cloud': - Type: A, Name: aksal.cloud, Content: 45.73.134.145, Notes: ddns:KACPER_NIX, Managed: true, ID: 508029248 - Type: NS, Name: aksal.cloud, Content: curitiba.porkbun.com, Notes: none, Managed: false, ID: 506591857 - Type: NS, Name: aksal.cloud, Content: fortaleza.porkbun.com, Notes: none, Managed: false, ID: 506591856 - Type: NS, Name: aksal.cloud, Content: maceio.porkbun.com, Notes: none, Managed: false, ID: 506591854 - Type: NS, Name: aksal.cloud, Content: salvador.porkbun.com, Notes: none, Managed: false, ID: 506591855 +Mon Nov 24 12:04:38 PM EST 2025 [INFO] Processing DNS record for: example.com (IP: 45.73.134.145) +Mon Nov 24 12:04:38 PM EST 2025 [INFO] Machine ID: EXAMPLE_MACHINE +Mon Nov 24 12:04:39 PM EST 2025 [INFO] Existing records for FQDN 'example.com': + Type: A, Name: example.com, Content: 45.73.134.145, Notes: ddns:EXAMPLE_MACHINE, Managed: true, ID: 508029248 + Type: NS, Name: example.com, Content: curitiba.porkbun.com, Notes: none, Managed: false, ID: 506591857 + Type: NS, Name: example.com, Content: fortaleza.porkbun.com, Notes: none, Managed: false, ID: 506591856 + Type: NS, Name: example.com, Content: maceio.porkbun.com, Notes: none, Managed: false, ID: 506591854 + Type: NS, Name: example.com, Content: salvador.porkbun.com, Notes: none, Managed: false, ID: 506591855 Mon Nov 24 12:04:39 PM EST 2025 [INFO] Record already exists with correct IP (45.73.134.145). No update needed. -Mon Nov 24 12:04:39 PM EST 2025 [INFO] Processing DNS record for: *.aksal.cloud (IP: 45.73.134.145) -Mon Nov 24 12:04:39 PM EST 2025 [INFO] Machine ID: KACPER_NIX -Mon Nov 24 12:04:40 PM EST 2025 [INFO] Existing records for FQDN '*.aksal.cloud': - Type: A, Name: *.aksal.cloud, Content: 45.73.134.145, Notes: ddns:KACPER_NIX, Managed: true, ID: 508029259 +Mon Nov 24 12:04:39 PM EST 2025 [INFO] Processing DNS record for: *.example.com (IP: 45.73.134.145) +Mon Nov 24 12:04:39 PM EST 2025 [INFO] Machine ID: EXAMPLE_MACHINE +Mon Nov 24 12:04:40 PM EST 2025 [INFO] Existing records for FQDN '*.example.com': + Type: A, Name: *.example.com, Content: 45.73.134.145, Notes: ddns:EXAMPLE_MACHINE, Managed: true, ID: 508029259 Mon Nov 24 12:04:40 PM EST 2025 [INFO] Record already exists with correct IP (45.73.134.145). No update needed. Mon Nov 24 12:04:40 PM EST 2025 [INFO] DNS update completed successfully @@ -310,18 +410,9 @@ Mon Nov 24 12:04:40 PM EST 2025 [INFO] DNS update completed successfully This script is provided as-is for use with Porkbun DNS services. -## Support - -For issues with: -- **This script**: Open an issue or review the code -- **Porkbun API**: Contact [Porkbun Support](https://porkbun.com/support) -- **DNS concepts**: See [Porkbun DNS Documentation](https://kb.porkbun.com/) - ## Contributing Improvements welcome! Consider adding: - IPv6 support (AAAA records) - Email notifications on IP change -- Systemd service file -- Docker container version -- Health check endpoint +- Health check endpoint \ No newline at end of file diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..1aabeee --- /dev/null +++ b/install.sh @@ -0,0 +1,185 @@ +#!/usr/bin/env bash +# +# Installation script for Porkbun Dynamic DNS systemd service +# +# This script will: +# 1. Verify prerequisites +# 2. Copy service files to systemd directory +# 3. Enable and start the timer +# 4. Show status and usage information + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +# Get script directory +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +echo "======================================" +echo "Porkbun DDNS Service Installer" +echo "======================================" +echo "" + +# Check if running as root +if [ "$EUID" -eq 0 ]; then + echo -e "${RED}Error: Do not run this script as root or with sudo${NC}" + echo "The script will request sudo privileges when needed." + exit 1 +fi + +# Verify required files exist +echo "Checking required files..." +REQUIRED_FILES=( + "$SCRIPT_DIR/updateDNS.sh" + "$SCRIPT_DIR/.env" + "$SCRIPT_DIR/porkbun-ddns.service" + "$SCRIPT_DIR/porkbun-ddns.timer" +) + +for file in "${REQUIRED_FILES[@]}"; do + if [ ! -f "$file" ]; then + echo -e "${RED}Error: Required file not found: $file${NC}" + exit 1 + fi +done +echo -e "${GREEN}✓ All required files found${NC}" +echo "" + +# Check if updateDNS.sh is executable +if [ ! -x "$SCRIPT_DIR/updateDNS.sh" ]; then + echo "Making updateDNS.sh executable..." + chmod +x "$SCRIPT_DIR/updateDNS.sh" + echo -e "${GREEN}✓ Made updateDNS.sh executable${NC}" +fi + +# Check for required commands +echo "Checking dependencies..." +MISSING_DEPS=() +for cmd in curl jq systemctl; do + if ! command -v "$cmd" &> /dev/null; then + MISSING_DEPS+=("$cmd") + fi +done + +if [ ${#MISSING_DEPS[@]} -gt 0 ]; then + echo -e "${RED}Error: Missing required dependencies: ${MISSING_DEPS[*]}${NC}" + echo "Please install them and try again." + exit 1 +fi +echo -e "${GREEN}✓ All dependencies installed${NC}" +echo "" + +# Verify .env file has required variables +echo "Validating .env configuration..." +if ! grep -q "^API_KEY=" "$SCRIPT_DIR/.env" || \ + ! grep -q "^SECRET_KEY=" "$SCRIPT_DIR/.env" || \ + ! grep -q "^DOMAIN=" "$SCRIPT_DIR/.env" || \ + ! grep -q "^MACHINE_ID=" "$SCRIPT_DIR/.env"; then + echo -e "${YELLOW}Warning: .env file may be incomplete${NC}" + echo "Required variables: API_KEY, SECRET_KEY, DOMAIN, MACHINE_ID" + echo "" + read -p "Continue anyway? (y/N) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 1 + fi +else + echo -e "${GREEN}✓ .env configuration looks good${NC}" +fi +echo "" + +# Test the script +echo "Testing DNS update script..." +if "$SCRIPT_DIR/updateDNS.sh"; then + echo -e "${GREEN}✓ Script test passed${NC}" +else + echo -e "${RED}Error: Script test failed${NC}" + echo "Please fix the script configuration before installing the service." + exit 1 +fi +echo "" + +# Check if service is already installed +if systemctl list-unit-files | grep -q "porkbun-ddns.timer"; then + echo -e "${YELLOW}Warning: porkbun-ddns service is already installed${NC}" + read -p "Reinstall? (y/N) " -n 1 -r + echo "" + if [[ ! $REPLY =~ ^[Yy]$ ]]; then + exit 0 + fi + echo "Stopping existing service..." + sudo systemctl stop porkbun-ddns.timer 2>/dev/null || true + sudo systemctl disable porkbun-ddns.timer 2>/dev/null || true +fi + +# Create temporary service files with correct paths +echo "Preparing service files..." +TEMP_SERVICE=$(mktemp) +TEMP_TIMER=$(mktemp) + +# Replace paths in service file +sed "s|/home/kacper/Documents/platformIO/email/porkbun|$SCRIPT_DIR|g" \ + "$SCRIPT_DIR/porkbun-ddns.service" > "$TEMP_SERVICE" + +# Update user/group in service file +CURRENT_USER=$(whoami) +CURRENT_GROUP=$(id -gn) +sed -i "s|User=kacper|User=$CURRENT_USER|g" "$TEMP_SERVICE" +sed -i "s|Group=kacper|Group=$CURRENT_GROUP|g" "$TEMP_SERVICE" + +# Copy timer file as-is +cp "$SCRIPT_DIR/porkbun-ddns.timer" "$TEMP_TIMER" + +# Install service files +echo "Installing systemd service files..." +sudo cp "$TEMP_SERVICE" /etc/systemd/system/porkbun-ddns.service +sudo cp "$TEMP_TIMER" /etc/systemd/system/porkbun-ddns.timer + +# Clean up temp files +rm "$TEMP_SERVICE" "$TEMP_TIMER" + +# Set proper permissions +sudo chmod 644 /etc/systemd/system/porkbun-ddns.service +sudo chmod 644 /etc/systemd/system/porkbun-ddns.timer + +echo -e "${GREEN}✓ Service files installed${NC}" +echo "" + +# Reload systemd +echo "Reloading systemd daemon..." +sudo systemctl daemon-reload +echo -e "${GREEN}✓ Systemd reloaded${NC}" +echo "" + +# Enable and start timer +echo "Enabling and starting porkbun-ddns.timer..." +sudo systemctl enable porkbun-ddns.timer +sudo systemctl start porkbun-ddns.timer +echo -e "${GREEN}✓ Service enabled and started${NC}" +echo "" + +# Show status +echo "======================================" +echo "Installation Complete!" +echo "======================================" +echo "" +echo "Service Status:" +sudo systemctl status porkbun-ddns.timer --no-pager -l || true +echo "" + +echo -e "${GREEN}The Porkbun DDNS service is now running!${NC}" +echo "" +echo "Useful commands:" +echo " View timer status: systemctl status porkbun-ddns.timer" +echo " View service logs: journalctl -u porkbun-ddns.service -f" +echo " List timers: systemctl list-timers" +echo " Run manually: sudo systemctl start porkbun-ddns.service" +echo " Stop service: sudo systemctl stop porkbun-ddns.timer" +echo " Uninstall: ./uninstall.sh" +echo "" +echo "The service will update DNS records every 5 minutes." +echo "Check logs with: journalctl -u porkbun-ddns.service -n 50" diff --git a/logs.sh b/logs.sh new file mode 100755 index 0000000..73981e6 --- /dev/null +++ b/logs.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# +# Log viewer for Porkbun Dynamic DNS systemd service +# +# This script provides convenient access to service logs via journalctl + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +SERVICE_NAME="porkbun-ddns.service" +TIMER_NAME="porkbun-ddns.timer" + +# Check if systemctl is available +if ! command -v systemctl &> /dev/null; then + echo -e "${RED}Error: systemctl not found${NC}" + echo "This script requires systemd." + exit 1 +fi + +# Check if journalctl is available +if ! command -v journalctl &> /dev/null; then + echo -e "${RED}Error: journalctl not found${NC}" + echo "This script requires journalctl." + exit 1 +fi + +# Function to show help +show_help() { + cat << EOF +${GREEN}Porkbun DDNS Log Viewer${NC} + +Usage: $0 [OPTION] + +Options: + -f, --follow Follow logs in real-time (like tail -f) + -n, --lines NUMBER Show last N lines (default: 50) + -t, --today Show logs from today only + -h, --hour Show logs from the last hour + -b, --boot Show logs since last boot + -a, --all Show all logs (no limit) + -s, --status Show service and timer status + -e, --errors Show only error messages + -v, --verbose Show verbose output (all log levels) + --help Show this help message + +Examples: + $0 Show last 50 log lines + $0 -f Follow logs in real-time + $0 -n 100 Show last 100 lines + $0 -t Show today's logs + $0 -h Show last hour's logs + $0 -e Show only errors + $0 -s Show service status + +EOF +} + +# Function to show status +show_status() { + echo -e "${BLUE}=== Timer Status ===${NC}" + systemctl status "$TIMER_NAME" --no-pager -l || true + echo "" + echo -e "${BLUE}=== Service Status ===${NC}" + systemctl status "$SERVICE_NAME" --no-pager -l || true + echo "" + echo -e "${BLUE}=== Next Scheduled Runs ===${NC}" + systemctl list-timers "$TIMER_NAME" --no-pager || true + echo "" + echo -e "${BLUE}=== Recent Runs ===${NC}" + journalctl -u "$SERVICE_NAME" -n 5 --no-pager -o short-precise +} + +# Parse command line arguments +FOLLOW=false +LINES=50 +SINCE="" +PRIORITY="" +SHOW_ALL=false +SHOW_STATUS=false + +if [ $# -eq 0 ]; then + # Default: show last 50 lines + : +else + while [[ $# -gt 0 ]]; do + case $1 in + -f|--follow) + FOLLOW=true + shift + ;; + -n|--lines) + LINES="$2" + shift 2 + ;; + -t|--today) + SINCE="today" + shift + ;; + -h|--hour) + SINCE="1 hour ago" + shift + ;; + -b|--boot) + SINCE="boot" + shift + ;; + -a|--all) + SHOW_ALL=true + shift + ;; + -s|--status) + SHOW_STATUS=true + shift + ;; + -e|--errors) + PRIORITY="err" + shift + ;; + -v|--verbose) + PRIORITY="" + shift + ;; + --help) + show_help + exit 0 + ;; + *) + echo -e "${RED}Error: Unknown option: $1${NC}" + echo "Use --help for usage information" + exit 1 + ;; + esac + done +fi + +# Check if service exists +if ! systemctl list-unit-files | grep -q "$SERVICE_NAME"; then + echo -e "${YELLOW}Warning: $SERVICE_NAME is not installed${NC}" + echo "Install the service first with: ./install.sh" + exit 1 +fi + +# Show status if requested +if [ "$SHOW_STATUS" = true ]; then + show_status + exit 0 +fi + +# Build journalctl command +CMD="journalctl -u $SERVICE_NAME" + +# Add follow flag +if [ "$FOLLOW" = true ]; then + CMD="$CMD -f" +fi + +# Add line limit +if [ "$SHOW_ALL" = false ] && [ "$FOLLOW" = false ]; then + CMD="$CMD -n $LINES" +fi + +# Add time filter +if [ -n "$SINCE" ]; then + if [ "$SINCE" = "boot" ]; then + CMD="$CMD -b" + else + CMD="$CMD --since \"$SINCE\"" + fi +fi + +# Add priority filter +if [ -n "$PRIORITY" ]; then + CMD="$CMD -p $PRIORITY" +fi + +# Add no-pager for non-follow mode +if [ "$FOLLOW" = false ]; then + CMD="$CMD --no-pager" +fi + +# Show what we're doing +if [ "$FOLLOW" = true ]; then + echo -e "${GREEN}Following logs for $SERVICE_NAME...${NC}" + echo "Press Ctrl+C to stop" + echo "" +elif [ -n "$SINCE" ]; then + echo -e "${GREEN}Showing logs since $SINCE...${NC}" + echo "" +elif [ "$SHOW_ALL" = true ]; then + echo -e "${GREEN}Showing all logs for $SERVICE_NAME...${NC}" + echo "" +else + echo -e "${GREEN}Showing last $LINES log lines for $SERVICE_NAME...${NC}" + echo "" +fi + +# Execute the command +eval "$CMD" diff --git a/porkbun-ddns.service b/porkbun-ddns.service new file mode 100644 index 0000000..a834a15 --- /dev/null +++ b/porkbun-ddns.service @@ -0,0 +1,31 @@ +[Unit] +Description=Porkbun Dynamic DNS Update +Documentation=file:///home/kacper/Documents/platformIO/email/porkbun/README.md +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +User=kacper +Group=kacper +WorkingDirectory=/home/kacper/Documents/platformIO/email/porkbun +ExecStart=/home/kacper/Documents/platformIO/email/porkbun/updateDNS.sh + +# Security hardening +PrivateTmp=yes +NoNewPrivileges=yes +ProtectSystem=strict +ProtectHome=read-only +ReadWritePaths=/home/kacper/Documents/platformIO/email/porkbun + +# Logging +StandardOutput=journal +StandardError=journal +SyslogIdentifier=porkbun-ddns + +# Restart on failure +Restart=on-failure +RestartSec=30 + +[Install] +WantedBy=multi-user.target diff --git a/porkbun-ddns.timer b/porkbun-ddns.timer new file mode 100644 index 0000000..d969130 --- /dev/null +++ b/porkbun-ddns.timer @@ -0,0 +1,17 @@ +[Unit] +Description=Porkbun Dynamic DNS Update Timer +Documentation=file:///home/kacper/Documents/platformIO/email/porkbun/README.md +Requires=porkbun-ddns.service + +[Timer] +# Run 2 minutes after boot +OnBootSec=2min + +# Run every 5 minutes +OnUnitActiveSec=5min + +# If the system was off when a timer should have run, run it on next boot +Persistent=true + +[Install] +WantedBy=timers.target diff --git a/uninstall.sh b/uninstall.sh new file mode 100755 index 0000000..6a4c890 --- /dev/null +++ b/uninstall.sh @@ -0,0 +1,149 @@ +#!/usr/bin/env bash +# +# Uninstallation script for Porkbun Dynamic DNS systemd service +# +# This script will: +# 1. Stop and disable the timer +# 2. Remove service files from systemd +# 3. Reload systemd daemon + +set -euo pipefail + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +NC='\033[0m' # No Color + +echo "======================================" +echo "Porkbun DDNS Service Uninstaller" +echo "======================================" +echo "" + +# Check if running as root +if [ "$EUID" -eq 0 ]; then + echo -e "${RED}Error: Do not run this script as root or with sudo${NC}" + echo "The script will request sudo privileges when needed." + exit 1 +fi + +# Check if systemctl is available +if ! command -v systemctl &> /dev/null; then + echo -e "${RED}Error: systemctl not found${NC}" + echo "This script requires systemd." + exit 1 +fi + +# Check if service is installed +if ! systemctl list-unit-files | grep -q "porkbun-ddns.timer"; then + echo -e "${YELLOW}porkbun-ddns service is not installed${NC}" + echo "Nothing to uninstall." + exit 0 +fi + +# Confirm uninstallation +echo -e "${YELLOW}This will remove the porkbun-ddns systemd service.${NC}" +echo "Your DNS update script and configuration will NOT be deleted." +echo "" +read -p "Continue with uninstallation? (y/N) " -n 1 -r +echo "" +if [[ ! $REPLY =~ ^[Yy]$ ]]; then + echo "Uninstallation cancelled." + exit 0 +fi +echo "" + +# Stop the timer if running +echo "Stopping porkbun-ddns.timer..." +if systemctl is-active --quiet porkbun-ddns.timer; then + sudo systemctl stop porkbun-ddns.timer + echo -e "${GREEN}✓ Timer stopped${NC}" +else + echo "Timer is not running" +fi +echo "" + +# Stop the service if running +echo "Stopping porkbun-ddns.service..." +if systemctl is-active --quiet porkbun-ddns.service; then + sudo systemctl stop porkbun-ddns.service + echo -e "${GREEN}✓ Service stopped${NC}" +else + echo "Service is not running" +fi +echo "" + +# Disable the timer +echo "Disabling porkbun-ddns.timer..." +if systemctl is-enabled --quiet porkbun-ddns.timer; then + sudo systemctl disable porkbun-ddns.timer + echo -e "${GREEN}✓ Timer disabled${NC}" +else + echo "Timer was not enabled" +fi +echo "" + +# Remove service files +echo "Removing service files..." +REMOVED=0 + +if [ -f /etc/systemd/system/porkbun-ddns.service ]; then + sudo rm /etc/systemd/system/porkbun-ddns.service + echo -e "${GREEN}✓ Removed porkbun-ddns.service${NC}" + ((REMOVED++)) +fi + +if [ -f /etc/systemd/system/porkbun-ddns.timer ]; then + sudo rm /etc/systemd/system/porkbun-ddns.timer + echo -e "${GREEN}✓ Removed porkbun-ddns.timer${NC}" + ((REMOVED++)) +fi + +if [ $REMOVED -eq 0 ]; then + echo -e "${YELLOW}No service files found to remove${NC}" +fi +echo "" + +# Remove override directory if it exists +if [ -d /etc/systemd/system/porkbun-ddns.service.d ]; then + sudo rm -rf /etc/systemd/system/porkbun-ddns.service.d + echo -e "${GREEN}✓ Removed service override directory${NC}" + echo "" +fi + +# Reload systemd +echo "Reloading systemd daemon..." +sudo systemctl daemon-reload +echo -e "${GREEN}✓ Systemd reloaded${NC}" +echo "" + +# Reset failed state if exists +sudo systemctl reset-failed porkbun-ddns.service 2>/dev/null || true +sudo systemctl reset-failed porkbun-ddns.timer 2>/dev/null || true + +# Verify uninstallation +echo "Verifying uninstallation..." +if systemctl list-unit-files | grep -q "porkbun-ddns"; then + echo -e "${RED}Warning: Some porkbun-ddns units still found${NC}" + systemctl list-unit-files | grep porkbun-ddns +else + echo -e "${GREEN}✓ All service units removed${NC}" +fi +echo "" + +echo "======================================" +echo "Uninstallation Complete!" +echo "======================================" +echo "" +echo -e "${GREEN}The porkbun-ddns systemd service has been removed.${NC}" +echo "" +echo "Your files are still available:" +echo " - updateDNS.sh (DNS update script)" +echo " - .env (configuration)" +echo " - porkbun-ddns.service (service template)" +echo " - porkbun-ddns.timer (timer template)" +echo "" +echo "You can:" +echo " - Reinstall with: ./install.sh" +echo " - Run manually with: ./updateDNS.sh" +echo " - Delete all files if no longer needed" diff --git a/updateDNS.sh b/updateDNS.sh new file mode 100755 index 0000000..b2921c5 --- /dev/null +++ b/updateDNS.sh @@ -0,0 +1,382 @@ +#!/usr/bin/env bash +# +# Porkbun Dynamic DNS Update Script +# Updates DNS A records with current public IP address +# Supports multi-machine failover configurations +# +# Author: Auto-generated +# License: MIT + +# Exit on error, undefined variables, and pipe failures +set -euo pipefail +# NOTE: +# - Critical operations should use explicit error checks: `if ! cmd; then ...; fi` +# - Some calls intentionally suppress errors with `||` to allow fallbacks (e.g. IP detection) +# - Be careful adding new commands to conditionals or pipelines; unexpected `set -e` behavior +# can cause early exits if errors aren't handled explicitly. + +# Trap errors and cleanup (final safety net; most errors are handled explicitly) +trap 'echo "Error on line $LINENO. Exit code: $?" >&2' ERR + +# Load environment variables from .env file +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +if [ -f "$SCRIPT_DIR/.env" ]; then + set -a # Mark variables for export + # Info-level log: configuration file found + printf '%s [INFO] Using environment file: %s/.env\n' "$(date)" "$SCRIPT_DIR" >&2 + source "$SCRIPT_DIR/.env" + set +a # Stop marking variables for export +else + printf '%s [ERROR] .env file not found in %s\n' "$(date)" "$SCRIPT_DIR" >&2 + exit 1 +fi + +# Validate required environment variables without leaking secrets +if [ -z "${API_KEY:-}" ] || [ -z "${SECRET_KEY:-}" ] || [ -z "${DOMAIN:-}" ]; then + printf '%s [ERROR] API_KEY, SECRET_KEY, and DOMAIN must be set in .env file\n' "$(date)" >&2 + printf '%s [ERROR] Current values:\n' "$(date)" >&2 + if [ -n "${API_KEY:-}" ]; then + printf '%s [ERROR] API_KEY=\n' "$(date)" >&2 + else + printf '%s [ERROR] API_KEY=\n' "$(date)" >&2 + fi + if [ -n "${SECRET_KEY:-}" ]; then + printf '%s [ERROR] SECRET_KEY=\n' "$(date)" >&2 + else + printf '%s [ERROR] SECRET_KEY=\n' "$(date)" >&2 + fi + printf '%s [ERROR] DOMAIN=%s\n' "$(date)" "${DOMAIN:-}" >&2 + exit 1 +fi + +# Warn if .env permissions are too permissive +if [ -f "$SCRIPT_DIR/.env" ]; then + env_perms=$(stat -c '%a' "$SCRIPT_DIR/.env" 2>/dev/null || echo "") + if [ -n "$env_perms" ] && [ "$env_perms" -gt 600 ]; then + printf '%s [WARN] %s/.env has permissions %s; consider restricting to 600 to protect secrets\n' "$(date)" "$SCRIPT_DIR" "$env_perms" >&2 + fi +fi + +# MACHINE_ID is required for multi-machine setups +if [ -z "${MACHINE_ID:-}" ]; then + printf '%s [WARN] MACHINE_ID not set in .env file\n' "$(date)" >&2 + printf '%s [INFO] Using hostname as MACHINE_ID: %s\n' "$(date)" "$(hostname)" >&2 + MACHINE_ID="$(hostname)" +fi + +# Validate MACHINE_ID doesn't contain special characters that could cause issues +if [[ "$MACHINE_ID" =~ [^a-zA-Z0-9_-] ]]; then + printf '%s [ERROR] MACHINE_ID contains invalid characters. Only alphanumeric, dash, and underscore allowed.\n' "$(date)" >&2 + exit 1 +fi + +PORKBUN_API_URL="https://api-ipv4.porkbun.com/api/json/v3" +NOTES_PREFIX="ddns:" + +# Optional dry-run mode. When DRY_RUN=1, destructive or mutating API calls +# (dns/create, dns/delete) are logged but not executed. +DRY_RUN=${DRY_RUN:-0} + +# Check for required commands +for cmd in curl jq; do + if ! command -v "$cmd" &> /dev/null; then + printf '%s [ERROR] Required command %s not found. Please install it.\n' "$(date)" "$cmd" >&2 + exit 1 + fi +done + +function get_public_ip() { + local ip="" + + # Try Porkbun's ping endpoint first (most reliable for their API) + local auth_json + if ! auth_json=$(jq -n \ + --arg apikey "$API_KEY" \ + --arg secretapikey "$SECRET_KEY" \ + '{apikey: $apikey, secretapikey: $secretapikey}' 2>&1); then + printf '%s [ERROR] Failed to create auth JSON: %s\n' "$(date)" "$auth_json" >&2 + exit 1 + fi + + local ping_response + ping_response=$(curl -s --max-time 10 --retry 2 -X POST "$PORKBUN_API_URL/ping" \ + -H "Content-Type: application/json" \ + -d "$auth_json" 2>&1 || echo "") + + if [ -n "$ping_response" ]; then + ip=$(echo "$ping_response" | jq -r '.yourIp // empty' 2>/dev/null || echo "") + if [ -n "$ip" ]; then + printf '%s [INFO] IP source: Porkbun API (/ping)\n' "$(date)" >&2 + fi + fi + + # Fallback methods if Porkbun ping fails + if [ -z "$ip" ]; then + ip=$(curl -s --max-time 5 --retry 2 https://api.ipify.org 2>/dev/null || echo "") + if [ -n "$ip" ]; then + printf '%s [INFO] IP source: api.ipify.org\n' "$(date)" >&2 + fi + fi + + if [ -z "$ip" ]; then + ip=$(curl -s --max-time 5 --retry 2 https://icanhazip.com 2>/dev/null || echo "") + if [ -n "$ip" ]; then + printf '%s [INFO] IP source: icanhazip.com\n' "$(date)" >&2 + fi + fi + + if [ -z "$ip" ]; then + printf '%s [ERROR] Failed to retrieve public IP address from any source\n' "$(date)" >&2 + exit 1 + fi + + # Validate IP format (basic IPv4 validation) + if ! [[ "$ip" =~ ^[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}$ ]]; then + printf '%s [ERROR] Invalid IP address format: %s\n' "$(date)" "$ip" >&2 + exit 1 + fi + + echo "$ip" +} + +function register_host_dns() { + local host="$1" + local domain="$2" + local public_ip="$3" + + # Validate inputs + if [ -z "$domain" ]; then + printf '%s [ERROR] Domain parameter is empty\n' "$(date)" >&2 + return 1 + fi + + if [ -z "$public_ip" ]; then + printf '%s [ERROR] Public IP parameter is empty\n' "$(date)" >&2 + return 1 + fi + + # Build FQDN for display + local fqdn + if [ -z "$host" ]; then + fqdn="$domain" + else + fqdn="${host}.${domain}" + fi + + printf '%s [INFO] Processing DNS record for: %s (IP: %s)\n' "$(date)" "$fqdn" "$public_ip" + printf '%s [INFO] Machine ID: %s\n' "$(date)" "$MACHINE_ID" + + # Create auth JSON for Porkbun API + local auth_json + if ! auth_json=$(jq -n \ + --arg apikey "$API_KEY" \ + --arg secretapikey "$SECRET_KEY" \ + '{apikey: $apikey, secretapikey: $secretapikey}' 2>&1); then + printf '%s [ERROR] Failed to create auth JSON: %s\n' "$(date)" "$auth_json" >&2 + return 1 + fi + + # Retrieve all DNS records for the domain + local records + if ! records=$(curl -s --max-time 10 --retry 2 -X POST "$PORKBUN_API_URL/dns/retrieve/$domain" \ + -H "Content-Type: application/json" \ + -d "$auth_json" 2>&1); then + printf '%s [ERROR] Failed to retrieve DNS records: %s\n' "$(date)" "$records" >&2 + return 1 + fi + + # Check if response is valid JSON + if ! echo "$records" | jq empty 2>/dev/null; then + printf '%s [ERROR] API returned invalid JSON. Response: %s\n' "$(date)" "$records" >&2 + printf '%s [ERROR] Check if API access is enabled for domain: %s\n' "$(date)" "$domain" >&2 + return 1 + fi + + # Check if API call was successful + local status + status=$(echo "$records" | jq -r '.status // "error"') + if [ "$status" == "ERROR" ]; then + local message + message=$(echo "$records" | jq -r '.message // "Unknown error"') + printf '%s [ERROR] Failed to retrieve DNS records. Message: %s\n' "$(date)" "$message" >&2 + printf '%s [ERROR] Make sure API access is enabled for domain: %s\n' "$(date)" "$domain" >&2 + return 1 + fi + + # Show what records exist for this FQDN + printf "%s [INFO] Existing records for FQDN '%s':\n" "$(date)" "$fqdn" + echo "$records" | jq -r --arg fqdn "$fqdn" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn) | " Type: \(.type), Name: \(.name), Content: \(.content), Notes: \(.notes // "none"), Managed: \( (.notes // "") | startswith($prefix) ), ID: \(.id)"' + + # Check for duplicate records with the same machine ID (only within managed records) + local machine_records + machine_records=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id)))') + local record_count + record_count=$(echo "$machine_records" | jq -s 'length') + + if [ "$record_count" -gt 1 ]; then + printf '%s [WARN] Found %d A records for machine %s (expected 0 or 1)\n' "$(date)" "$record_count" "$MACHINE_ID" >&2 + printf '%s [WARN] This may indicate concurrent script execution or previous failures. Cleaning up...\n' "$(date)" >&2 + # Will be cleaned up in the deletion step below + fi + + # Check if this machine already has a record with the current IP + local existing_ip + existing_ip=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id))) | .content' | head -1) + + if [ "$existing_ip" == "$public_ip" ] && [ "$record_count" -eq 1 ]; then + printf "%s [INFO] Record already exists with correct IP (%s). No update needed.\n" "$(date)" "$public_ip" + return 0 + fi + + # IP has changed, log it + if [ -n "$existing_ip" ] && [ "$existing_ip" != "$public_ip" ]; then + printf "%s [INFO] IP changed from %s to %s\n" "$(date)" "$existing_ip" "$public_ip" + fi + + # If we have duplicates, we need to clean them up + if [ "$record_count" -gt 1 ]; then + printf "%s [INFO] Cleaning up %d duplicate records for machine '%s'\n" "$(date)" "$record_count" "$MACHINE_ID" + fi + + # Delete A/ALIAS/CNAME records that belong to this MACHINE_ID within the managed namespace + # Managed records are identified by notes starting with NOTES_PREFIX (e.g. "ddns:") + printf "%s [INFO] Deleting managed records for machine '%s' (notes starting with '%s')\n" "$(date)" "$MACHINE_ID" "$NOTES_PREFIX" + + local delete_count=0 + while IFS= read -r record_id; do + if [ -n "$record_id" ]; then + printf "%s [INFO] Deleting record ID: %s\n" "$(date)" "$record_id" + + if [ "$DRY_RUN" -eq 1 ]; then + printf "%s [INFO] DRY RUN: Skipping actual delete for record ID %s\n" "$(date)" "$record_id" + else + if curl -s --max-time 10 --retry 2 -X POST "$PORKBUN_API_URL/dns/delete/$domain/$record_id" \ + -H "Content-Type: application/json" \ + -d "$auth_json" > /dev/null 2>&1; then + ((delete_count++)) || true + else + printf '%s [WARN] Failed to delete record ID: %s\n' "$(date)" "$record_id" >&2 + fi + fi + fi + done < <(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '.records[] | select(.name == $fqdn and (.type == "A" or .type == "ALIAS" or .type == "CNAME") and ((.notes // "") == ($prefix + $machine_id))) | .id') + + if [ $delete_count -gt 0 ]; then + printf "%s [INFO] Deleted %d record(s)\n" "$(date)" "$delete_count" + fi + + # Verify no duplicates remain before creating new record + # Re-fetch records to ensure we have latest state + if ! records=$(curl -s --max-time 10 --retry 2 -X POST "$PORKBUN_API_URL/dns/retrieve/$domain" \ + -H "Content-Type: application/json" \ + -d "$auth_json" 2>&1); then + printf '%s [WARN] Failed to re-verify DNS records after deletion: %s\n' "$(date)" "$records" >&2 + # Continue anyway - deletion likely succeeded + else + local remaining_count + remaining_count=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '[.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id))) ] | length') + if [ "$remaining_count" -gt 0 ]; then + printf '%s [ERROR] Found %d A record(s) still present for machine %s after deletion\n' "$(date)" "$remaining_count" "$MACHINE_ID" >&2 + printf '%s [ERROR] This should not happen. Possible API delay or concurrent execution.\n' "$(date)" >&2 + return 1 + fi + fi + + # Create new A record for this machine's IP with machine ID in notes + printf "%s [INFO] Creating new A record for '%s' with IP: %s\n" "$(date)" "$fqdn" "$public_ip" + + # Build JSON with machine ID in notes field within managed namespace + local notes_value + notes_value="${NOTES_PREFIX}${MACHINE_ID}" + local create_json + if ! create_json=$(jq -n \ + --arg apikey "$API_KEY" \ + --arg secretapikey "$SECRET_KEY" \ + --arg name "$host" \ + --arg type "A" \ + --arg content "$public_ip" \ + --arg notes "$notes_value" \ + '{apikey: $apikey, secretapikey: $secretapikey, name: $name, type: $type, content: $content, ttl: 300, notes: $notes}' 2>&1); then + printf '%s [ERROR] Failed to create JSON for new record: %s\n' "$(date)" "$create_json" >&2 + return 1 + fi + + if [ "$DRY_RUN" -eq 1 ]; then + printf "%s [INFO] DRY RUN: Skipping actual create for '%s' (would create A %s -> %s with notes '%s')\n" "$(date)" "$fqdn" "$fqdn" "$public_ip" "$notes_value" + return 0 + fi + + local create_response + if ! create_response=$(curl -s --max-time 10 --retry 2 -X POST "$PORKBUN_API_URL/dns/create/$domain" \ + -H "Content-Type: application/json" \ + -d "$create_json" 2>&1); then + printf '%s [ERROR] Failed to create DNS record: %s\n' "$(date)" "$create_response" >&2 + return 1 + fi + + local create_status + create_status=$(echo "$create_response" | jq -r '.status // "error"') + if [ "$create_status" == "SUCCESS" ]; then + printf "%s [INFO] Successfully created A record for %s (machine: %s)\n" "$(date)" "$fqdn" "$MACHINE_ID" + + # Final verification: ensure exactly one record exists + if records=$(curl -s --max-time 10 --retry 2 -X POST "$PORKBUN_API_URL/dns/retrieve/$domain" \ + -H "Content-Type: application/json" \ + -d "$auth_json" 2>&1); then + local final_count + final_count=$(echo "$records" | jq -r --arg fqdn "$fqdn" --arg machine_id "$MACHINE_ID" --arg prefix "$NOTES_PREFIX" '[.records[] | select(.name == $fqdn and .type == "A" and ((.notes // "") == ($prefix + $machine_id))) ] | length') + if [ "$final_count" -eq 1 ]; then + printf "%s [INFO] Verified: Exactly 1 A record exists for machine '%s'\n" "$(date)" "$MACHINE_ID" + elif [ "$final_count" -gt 1 ]; then + printf '%s [WARN] Found %d A records for machine %s after creation (expected 1)\n' "$(date)" "$final_count" "$MACHINE_ID" >&2 + printf '%s [WARN] This may indicate concurrent script execution. The extra records will be cleaned up on next run.\n' "$(date)" >&2 + fi + fi + + return 0 + else + local error_msg + error_msg=$(echo "$create_response" | jq -r '.message // "Unknown error"') + printf '%s [ERROR] Failed to create DNS record. Message: %s\n' "$(date)" "$error_msg" >&2 + return 1 + fi +} + +# Main execution +main() { + local exit_code=0 + + # Get public IP + local pip + if ! pip=$(get_public_ip); then + printf '%s [ERROR] Failed to get public IP\n' "$(date)" >&2 + exit 1 + fi + + printf "%s [INFO] Current Public IP: %s\n" "$(date)" "$pip" + printf "%s [INFO] Machine ID: %s\n" "$(date)" "$MACHINE_ID" + printf "%s [INFO] Domain: %s\n\n" "$(date)" "$DOMAIN" + + # Update DNS records for the configured domain + # Both root domain and wildcard will have this machine's A record + if ! register_host_dns "" "$DOMAIN" "$pip"; then + printf '%s [ERROR] Failed to update root domain DNS record\n' "$(date)" >&2 + exit_code=1 + fi + + if ! register_host_dns "*" "$DOMAIN" "$pip"; then + printf '%s [ERROR] Failed to update wildcard DNS record\n' "$(date)" >&2 + exit_code=1 + fi + + if [ $exit_code -eq 0 ]; then + printf "\n%s [INFO] DNS update completed successfully\n" "$(date)" + else + printf "\n%s [ERROR] DNS update completed with errors\n" "$(date)" >&2 + fi + + exit $exit_code +} + +# Run main function +main