First Commit

This commit is contained in:
KacperLa 2025-11-24 13:17:00 -05:00
parent 51ca26f5ce
commit f961bc6c5a
8 changed files with 1123 additions and 61 deletions

3
.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
# Ignore environment files containing secrets
.env
*.env

211
README.md
View file

@ -41,9 +41,8 @@ SECRET_KEY=sk1_your_secret_key_here
# Required: The domain to update # Required: The domain to update
DOMAIN=example.com DOMAIN=example.com
# Required for multi-machine setup: Unique identifier for this machine # Required: Name of the machine for tracking
# Each machine should have a different MACHINE_ID MACHINE_ID="exmample_machine"
MACHINE_ID=server1
``` ```
### Getting Porkbun API Credentials ### 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, - If you ever want to manually take control of a record that was created by this script,
you can remove or change the `ddns:<MACHINE_ID>` note and the script will stop managing it. you can remove or change the `ddns:<MACHINE_ID>` 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 ## Dry-run mode
You can run the script in **dry-run** mode to see what it would do without actually 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, This is useful when first configuring the script or making changes to your DNS setup,
so you can verify behavior before applying it. 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 ## How It Works
1. **Load Configuration** - Reads API credentials and settings from `.env` 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 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 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 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) 6. **Delete Own Records** - Removes this machine's previous A records (by matching notes field)
7. **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. **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. **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
10. **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:<MACHINE_ID>`). Other records in the > (those whose notes are exactly `ddns:<MACHINE_ID>`). Other records in the
> same zone are left untouched. > 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 1. **Immediately revoke** the API keys in your Porkbun account
2. **Generate new keys** and update your `.env` file 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 ## 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" ### Error: "API access not enabled"
1. Log in to Porkbun 1. Log in to Porkbun
2. Go to your domain settings 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:<MACHINE_ID>`) - Check DNS records in Porkbun dashboard to see notes field (look for `ddns:<MACHINE_ID>`)
### IP not updating ### IP not updating
- Check script is running (add to cron for automatic updates) - Check script is running
- Verify your public IP actually changed - Verify your public IP actually changed
- Check Porkbun API status - Check Porkbun API status
- Review logs for error messages - 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 Mon Nov 24 12:04:38 PM EST 2025 [INFO] Using environment file: /home/user/porkbun/.env
IP source: Porkbun API (/ping) 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] 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] Machine ID: EXAMPLE_MACHINE
Mon Nov 24 12:04:38 PM EST 2025 [INFO] Domain: aksal.cloud 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] Processing DNS record for: example.com (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] Machine ID: EXAMPLE_MACHINE
Mon Nov 24 12:04:39 PM EST 2025 [INFO] Existing records for FQDN 'aksal.cloud': Mon Nov 24 12:04:39 PM EST 2025 [INFO] Existing records for FQDN 'example.com':
Type: A, Name: aksal.cloud, Content: 45.73.134.145, Notes: ddns:KACPER_NIX, Managed: true, ID: 508029248 Type: A, Name: example.com, Content: 45.73.134.145, Notes: ddns:EXAMPLE_MACHINE, Managed: true, ID: 508029248
Type: NS, Name: aksal.cloud, Content: curitiba.porkbun.com, Notes: none, Managed: false, ID: 506591857 Type: NS, Name: example.com, 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: example.com, 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: example.com, 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 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] 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] Processing DNS record for: *.example.com (IP: 45.73.134.145)
Mon Nov 24 12:04:39 PM EST 2025 [INFO] Machine ID: KACPER_NIX 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 '*.aksal.cloud': Mon Nov 24 12:04:40 PM EST 2025 [INFO] Existing records for FQDN '*.example.com':
Type: A, Name: *.aksal.cloud, Content: 45.73.134.145, Notes: ddns:KACPER_NIX, Managed: true, ID: 508029259 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] 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 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. 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 ## Contributing
Improvements welcome! Consider adding: Improvements welcome! Consider adding:
- IPv6 support (AAAA records) - IPv6 support (AAAA records)
- Email notifications on IP change - Email notifications on IP change
- Systemd service file
- Docker container version
- Health check endpoint - Health check endpoint

185
install.sh Executable file
View file

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

204
logs.sh Executable file
View file

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

31
porkbun-ddns.service Normal file
View file

@ -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

17
porkbun-ddns.timer Normal file
View file

@ -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

149
uninstall.sh Executable file
View file

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

382
updateDNS.sh Executable file
View file

@ -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=<set>\n' "$(date)" >&2
else
printf '%s [ERROR] API_KEY=<not set>\n' "$(date)" >&2
fi
if [ -n "${SECRET_KEY:-}" ]; then
printf '%s [ERROR] SECRET_KEY=<set>\n' "$(date)" >&2
else
printf '%s [ERROR] SECRET_KEY=<not set>\n' "$(date)" >&2
fi
printf '%s [ERROR] DOMAIN=%s\n' "$(date)" "${DOMAIN:-<not set>}" >&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:<machine_id>")
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