release: v0.1.0

This commit is contained in:
LC mac
2026-01-13 23:55:58 +08:00
parent d60c89201f
commit 9c252cf92c
6 changed files with 381 additions and 127 deletions

30
Dockerfile Normal file
View File

@@ -0,0 +1,30 @@
# Build stage
FROM golang:1.23-alpine AS build
WORKDIR /app
# Cache deps
COPY go.mod go.sum ./
RUN go mod download
# Copy source and build
COPY . .
RUN go build -o ddns-updater main.go
# Runtime stage
FROM alpine:latest
WORKDIR /app
# Non-root user (optional)
RUN adduser -D ddns
USER ddns
# Copy binary
COPY --from=build /app/ddns-updater /usr/local/bin/ddns-updater
# Config volume
VOLUME ["/config"]
ENTRYPOINT ["/usr/local/bin/ddns-updater"]
CMD ["-config", "/config/config.yaml"]

17
LICENSE
View File

@@ -1,18 +1,9 @@
MIT License
# MIT License
Copyright (c) 2026 kccleoc
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
associated documentation files (the "Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
following conditions:
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all copies or substantial
portions of the Software.
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
USE OR OTHER DEALINGS IN THE SOFTWARE.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

92
Makefile Normal file
View File

@@ -0,0 +1,92 @@
APP_NAME = ddns-updater
BIN_DIR = bin
VERSION = 1.0.0
# Go parameters
GOCMD = go
GOBUILD = $(GOCMD) build
GOCLEAN = $(GOCMD) clean
GORUN = $(GOCMD) run
# Build targets
.PHONY: all build clean run init-config install install-linux install-macos service-linux service-macos
all: build
# Build binary for current platform
build:
mkdir -p $(BIN_DIR)
$(GOBUILD) -o $(BIN_DIR)/$(APP_NAME) -ldflags "-X main.Version=$(VERSION)" main.go
@echo "Built $(BIN_DIR)/$(APP_NAME)"
# Build for multiple platforms
build-all:
mkdir -p $(BIN_DIR)
GOOS=linux GOARCH=amd64 $(GOBUILD) -o $(BIN_DIR)/$(APP_NAME)-linux-amd64 main.go
GOOS=linux GOARCH=arm64 $(GOBUILD) -o $(BIN_DIR)/$(APP_NAME)-linux-arm64 main.go
GOOS=darwin GOARCH=amd64 $(GOBUILD) -o $(BIN_DIR)/$(APP_NAME)-darwin-amd64 main.go
GOOS=darwin GOARCH=arm64 $(GOBUILD) -o $(BIN_DIR)/$(APP_NAME)-darwin-arm64 main.go
@echo "Built all platform binaries in $(BIN_DIR)/"
# Run locally
run:
$(GORUN) main.go -config config.yaml
# Generate config template
init-config:
$(GORUN) main.go -init-config > config.yaml
@echo "Generated config.yaml - edit with your Cloudflare credentials"
# Clean build artifacts
clean:
$(GOCLEAN)
rm -rf $(BIN_DIR)
# Install binary system-wide
install: build
sudo install -m 755 $(BIN_DIR)/$(APP_NAME) /usr/local/bin/$(APP_NAME)
@echo "Installed to /usr/local/bin/$(APP_NAME)"
# Linux-specific installation
install-linux: install
sudo mkdir -p /etc/ddns-updater
@if [ ! -f /etc/ddns-updater/config.yaml ]; then \
sudo cp config.yaml /etc/ddns-updater/config.yaml; \
sudo chmod 600 /etc/ddns-updater/config.yaml; \
echo "Config installed to /etc/ddns-updater/config.yaml"; \
else \
echo "Config already exists at /etc/ddns-updater/config.yaml"; \
fi
# macOS-specific installation
install-macos: install
sudo mkdir -p /usr/local/etc
sudo mkdir -p /usr/local/var/log
@if [ ! -f /usr/local/etc/ddns-updater.yaml ]; then \
sudo cp config.yaml /usr/local/etc/ddns-updater.yaml; \
sudo chmod 600 /usr/local/etc/ddns-updater.yaml; \
echo "Config installed to /usr/local/etc/ddns-updater.yaml"; \
else \
echo "Config already exists at /usr/local/etc/ddns-updater.yaml"; \
fi
# Set up systemd service (Linux)
service-linux:
@echo "Creating systemd service..."
@echo "[Unit]\nDescription=DDNS Updater Service\nAfter=network-online.target\nWants=network-online.target\n\n[Service]\nType=simple\nExecStart=/usr/local/bin/$(APP_NAME) -config /etc/ddns-updater/config.yaml\nRestart=always\nRestartSec=10\nUser=root\nWorkingDirectory=/usr/local/bin\n\n[Install]\nWantedBy=multi-user.target" | sudo tee /etc/systemd/system/ddns-updater.service >/dev/null
@echo "\nSystemd service created. Run these commands:"
@echo " sudo systemctl daemon-reload"
@echo " sudo systemctl enable ddns-updater"
@echo " sudo systemctl start ddns-updater"
@echo " sudo systemctl status ddns-updater"
# Set up launchd service (macOS)
service-macos:
@echo "Creating launchd plist..."
@mkdir -p ~/Library/LaunchAgents
@echo "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE plist PUBLIC \"-//Apple//DTD PLIST 1.0//EN\" \"http://www.apple.com/DTDs/PropertyList-1.0.dtd\">\n<plist version=\"1.0\">\n<dict>\n <key>Label</key>\n <string>com.ddns-updater</string>\n <key>ProgramArguments</key>\n <array>\n <string>/usr/local/bin/$(APP_NAME)</string>\n <string>-config</string>\n <string>/usr/local/etc/ddns-updater.yaml</string>\n </array>\n <key>RunAtLoad</key>\n <true/>\n <key>KeepAlive</key>\n <true/>\n <key>StandardOutPath</key>\n <string>/usr/local/var/log/ddns-updater.log</string>\n <key>StandardErrorPath</key>\n <string>/usr/local/var/log/ddns-updater.err</string>\n</dict>\n</plist>" > ~/Library/LaunchAgents/com.ddns-updater.plist
@echo "\nLaunchd plist created. Run these commands:"
@echo " launchctl load ~/Library/LaunchAgents/com.ddns-updater.plist"
@echo " launchctl start com.ddns-updater"
@echo "\nView logs with:"
@echo " tail -f /usr/local/var/log/ddns-updater.log"

290
README.md
View File

@@ -1,70 +1,149 @@
# ddns-updater
# DDNS Updater
A small Go daemon that keeps Cloudflare A records in sync with your current public IPv4 address.
It reads a YAML config, checks your IP periodically, compares it to existing DNS records, and only updates when needed.
A lightweight dynamic DNS updater for Cloudflare written in Go. Automatically detects your public IP changes and updates specified DNS A records.
## Features
- Uses Cloudflare API token (modern auth)
- Supports multiple subdomains in one zone
- IPv4 only (A records)
- User-defined check interval (minutes)
- Retries on failure, then logs and skips
- Structured JSON logging to file
- Automatic public IP detection with fallback services
- Multiple subdomain support
- Cloudflare API v4 integration
- Configurable check intervals
- Retry logic with exponential backoff
- Structured JSON logging
- Graceful shutdown handling
- Cross-platform (Linux, macOS)
## Requirements
## Quick Start
- Go 1.21+ (tested with 1.23)
- A Cloudflare account
- API token with:
- Zone:DNS:Edit
- Zone:Zone:Read
- Existing zone and A records for your subdomains
## Installation
### 1. Generate configuration template
```bash
# 1. Create project directory
mkdir ddns-updater
cd ddns-updater
# 2. Initialize module
go mod init github.com/yourname/ddns-updater
# 3. Add files
# - main.go
# - config.yaml
# - (this README.md)
# 4. Fetch dependencies
go mod tidy
# 5. Build binary (current platform)
go build -o ddns-updater
# 6. Build for Linux (LXC, etc.)
GOOS=linux GOARCH=amd64 go build -o ddns-updater-linux
make init-config
```
Copy the resulting binary and `config.yaml` onto your target machine or LXC container, e.g. `/opt/ddns-updater`.
This creates `config.yaml` with default settings.
## Configuration
### 2. Get Cloudflare credentials
Create `config.yaml` in the same directory as the binary:
#### Generate API Token
1. Log in to [Cloudflare Dashboard](https://dash.cloudflare.com)
2. Go to **Profile****API Tokens**
3. Click **Create Token**
4. Use the **Edit zone DNS** template or create a custom token with:
- **Permissions**: Zone → DNS → Edit, Zone → Zone → Read
- **Zone Resources**: Include → Specific zone → (select your domain)
5. Copy the token (you'll only see it once)
#### Find Zone ID
1. In Cloudflare Dashboard, select your domain
2. Scroll down on the **Overview** page
3. Find **Zone ID** in the right sidebar under **API** section
4. Copy the Zone ID
### 3. Configure
Edit `config.yaml`:
```yaml
check_interval: 5 # minutes (e.g. 5, 15, 30)
cloudflare:
api_token: "your-token-here" # From step 2
zone_id: "your-zone-id-here" # From step 2
zone_name: "example.com" # Your domain
subdomains:
- name: "home" # Updates home.example.com
proxied: false
- name: "vpn"
proxied: true # Route through Cloudflare proxy
```
### 4. Run locally (test)
```bash
make run
```
Logs will show IP detection and DNS updates in real-time.
## Production Deployment
### Linux (systemd)
#### Install and set up service
```bash
# Build and install binary
make install-linux
# Edit config with your credentials
sudo nano /etc/ddns-updater/config.yaml
# Create systemd service
make service-linux
# Enable and start
sudo systemctl daemon-reload
sudo systemctl enable ddns-updater
sudo systemctl start ddns-updater
# Check status
sudo systemctl status ddns-updater
# View logs
sudo journalctl -u ddns-updater -f
```
#### Service management
```bash
sudo systemctl stop ddns-updater # Stop service
sudo systemctl restart ddns-updater # Restart service
sudo systemctl disable ddns-updater # Disable auto-start
```
### macOS (launchd)
#### Install and set up service
```bash
# Build and install binary
make install-macos
# Edit config with your credentials
sudo nano /usr/local/etc/ddns-updater.yaml
# Create launchd service
make service-macos
# Load and start
launchctl load ~/Library/LaunchAgents/com.ddns-updater.plist
launchctl start com.ddns-updater
# View logs
tail -f /usr/local/var/log/ddns-updater.log
```
#### Service management
```bash
launchctl stop com.ddns-updater # Stop service
launchctl unload ~/Library/LaunchAgents/com.ddns-updater.plist # Unload service
```
## Configuration Reference
```yaml
# Check interval in minutes (how often to check for IP changes)
check_interval: 5
cloudflare:
api_token: "YOUR_CF_API_TOKEN"
zone_id: "YOUR_ZONE_ID"
api_token: "your-api-token"
zone_id: "your-zone-id"
zone_name: "example.com"
subdomains:
- name: "lc"
proxied: false
- name: "git"
proxied: false
- name: "mempool"
proxied: false
- name: "subdomain"
proxied: false # true = route through Cloudflare proxy (orange cloud)
logging:
file: "ddns-updater.log"
@@ -76,78 +155,77 @@ retry:
webhook:
enabled: false
url: ""
url: "" # Optional webhook for notifications
```
- `check_interval`: how often to check your current IP (in minutes).
- `subdomains`: just the left part of the name (e.g. `git` for `git.example.com`).
- `proxied`: `true` for orange-cloud (proxied), `false` for DNS only.
- `logging.file`: log file path, relative to the working directory.
- `retry`: per-subdomain retry behavior when the Cloudflare API or network fails temporarily.
## Usage
### Direct run
From the directory containing the binary and `config.yaml`:
## Building from Source
```bash
./ddns-updater
# Build for current platform
make build
# Build for all platforms
make build-all
# Clean build artifacts
make clean
```
The process will:
Built binaries will be in `./bin/`
1. Load `config.yaml`.
2. Detect current public IPv4 using external services.
3. For each subdomain:
- Fetch its current A record from Cloudflare.
- If the IP differs, update the record.
4. Sleep for `check_interval` minutes and repeat.
5. Log events to `ddns-updater.log`.
## Troubleshooting
### Run in background (simple)
### Authentication errors
- Verify API token has correct permissions (Zone → DNS → Edit, Zone → Zone → Read)
- Confirm token is scoped to the correct zone
- Check token hasn't expired
### DNS record not found
- Ensure `zone_name` matches your Cloudflare domain exactly
- Verify subdomain exists in Cloudflare DNS settings
- Check subdomain name doesn't include the domain (use `home`, not `home.example.com`)
### Service won't start
**Linux:**
```bash
nohup ./ddns-updater >/dev/null 2>&1 &
sudo journalctl -u ddns-updater -n 50
```
### Example systemd service (on Linux/LXC)
Create `/etc/systemd/system/ddns-updater.service`:
```ini
[Unit]
Description=Cloudflare DDNS Updater
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/opt/ddns-updater
ExecStart=/opt/ddns-updater/ddns-updater
Restart=on-failure
RestartSec=10
[Install]
WantedBy=multi-user.target
```
Then:
**macOS:**
```bash
sudo systemctl daemon-reload
sudo systemctl enable ddns-updater
sudo systemctl start ddns-updater
sudo systemctl status ddns-updater
cat /usr/local/var/log/ddns-updater.err
```
Logs will go to `ddns-updater.log` in `/opt/ddns-updater` plus systemds journal.
### Docker deployment
## Updating
To update the binary:
Build the image:
```bash
git pull # if you put it in a repo
go build -o ddns-updater
sudo systemctl restart ddns-updater
docker build -t yourname/ddns-updater:latest .
```
Run with a local config file:
```bash
cp config.yaml.example config.yaml # or make init-config
# edit config.yaml with your Cloudflare settings
docker run -d \
--name ddns-updater \
--restart unless-stopped \
-v $(pwd)/config.yaml:/config/config.yaml:ro \
yourname/ddns-updater:latest
```
Or with Docker Compose:
```bash
docker compose up -d
```
The container runs in the foreground inside Docker; restarts are handled by Dockers restart policy.

7
docker-compose.yaml Normal file
View File

@@ -0,0 +1,7 @@
services:
ddns-updater:
image: yourname/ddns-updater:latest
container_name: ddns-updater
restart: unless-stopped
volumes:
- ./config.yaml:/config/config.yaml:ro

66
main.go
View File

@@ -25,6 +25,7 @@ type Config struct {
Cloudflare struct {
APIToken string `yaml:"api_token"`
ZoneID string `yaml:"zone_id"`
ZoneName string `yaml:"zone_name"` // <- add this
Subdomains []struct {
Name string `yaml:"name"`
Proxied bool `yaml:"proxied"`
@@ -54,10 +55,15 @@ type CloudflareDNSRecord struct {
TTL int `json:"ttl"`
}
// CloudflareResponse is the generic API response structure
type CloudflareError struct {
Code int `json:"code"`
Message string `json:"message"`
}
type CloudflareResponse[T any] struct {
Success bool `json:"success"`
Errors []string `json:"errors"`
Errors []CloudflareError `json:"errors"` // ← was `string`, now `[]CloudflareError`
Messages []string `json:"messages"`
Result T `json:"result"`
}
@@ -71,8 +77,14 @@ type DNSUpdater struct {
func main() {
configPath := flag.String("config", "config.yaml", "path to config file")
initConfig := flag.Bool("init-config", false, "print example config to stdout")
flag.Parse()
if *initConfig {
printConfigTemplate()
return
}
// Load configuration
config, err := loadConfig(*configPath)
if err != nil {
@@ -111,6 +123,37 @@ func main() {
logger.Info("DDNS Updater stopped gracefully")
}
func printConfigTemplate() {
tmpl := `# Check interval in minutes (5, 15, or 30)
check_interval: 5
cloudflare:
api_token: "your-api-token-here"
zone_id: "your-zone-id-here"
zone_name: "example.com"
subdomains:
- name: "lc"
proxied: false
- name: "git"
proxied: false
- name: "mempool"
proxied: false
logging:
file: "ddns-updater.log"
level: "info"
retry:
max_attempts: 3
delay_seconds: 5
webhook:
enabled: false
url: ""
`
fmt.Print(tmpl)
}
// loadConfig reads and parses the YAML configuration file
func loadConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
@@ -201,6 +244,13 @@ func (u *DNSUpdater) run(ctx context.Context) {
}
}
func (u *DNSUpdater) fqdn(subdomain string) string {
if subdomain == "@" || subdomain == "" {
return u.config.Cloudflare.ZoneName // root record
}
return subdomain + "." + u.config.Cloudflare.ZoneName
}
// updateDNS checks current IP and updates DNS records if needed
func (u *DNSUpdater) updateDNS(ctx context.Context) error {
// Get current public IP
@@ -334,9 +384,11 @@ func (u *DNSUpdater) updateSubdomainAttempt(ctx context.Context, subdomain, ip s
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records/%s",
u.config.Cloudflare.ZoneID, recordID)
name := u.fqdn(subdomain)
payload := map[string]any{
"type": "A",
"name": subdomain,
"name": name,
"content": ip,
"proxied": proxied,
"ttl": 300,
@@ -380,8 +432,12 @@ func (u *DNSUpdater) updateSubdomainAttempt(ctx context.Context, subdomain, ip s
// getDNSRecord retrieves the record ID and current IP for a subdomain
func (u *DNSUpdater) getDNSRecord(ctx context.Context, subdomain string) (recordID, currentIP string, err error) {
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s",
u.config.Cloudflare.ZoneID, subdomain)
name := u.fqdn(subdomain)
url := fmt.Sprintf(
"https://api.cloudflare.com/client/v4/zones/%s/dns_records?type=A&name=%s",
u.config.Cloudflare.ZoneID,
name,
)
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {