Skip to content

Instantly share code, notes, and snippets.

@ashafq
Last active October 26, 2025 13:53
Show Gist options
  • Save ashafq/5387ae753881e3b13fe9fd1067558156 to your computer and use it in GitHub Desktop.
Save ashafq/5387ae753881e3b13fe9fd1067558156 to your computer and use it in GitHub Desktop.
/**
* The MIT License (MIT)
*
* Copyright (c) 2025 Colahall, LLC.
*
* 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 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.
*/
// Package duckdns provides a Go program to update a DuckDNS domain with the
// current public IP address.
package main
import (
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"log/syslog"
"net/http"
"os"
"path/filepath"
"strings"
"time"
)
// DuckDNS API URL
const (
duckdns_url = "https://www.duckdns.org/update?domains=%s&token=%s&verbose=true"
)
// Config represents a configuration for the DuckDNS client
type Config struct {
Domain string `json:"domain"`
Token string `json:"token"`
UpdateInterval time.Duration `json:"update_interval"`
}
// Entry point for the program
//
// There are no command line arguments for this program. It will read the
// configuration from a JSON file and run the update task every 15 minutes
// or whatever is set at update_interval.
func main() {
// set up syslog
logger, err := syslog.New(syslog.LOG_NOTICE|syslog.LOG_DAEMON, "duckdns-client")
if err != nil {
log.Fatalf("syslog setup error: %v", err)
}
defer logger.Close()
// Load the files from the config file
cfg, err := loadConfig()
if err != nil {
logger.Err(fmt.Sprintf("Error loading config file: %v", err))
return
}
logger.Info(fmt.Sprintf("Update interval: %v", cfg.UpdateInterval))
// Update the first time
updateDuckDns(logger, cfg)
// Wait until time is a modulo of update_interval before running again
now := time.Now()
nextUpdate := now.Truncate(cfg.UpdateInterval).Add(cfg.UpdateInterval)
updateDuration := time.Until(nextUpdate)
logger.Info(fmt.Sprintf("Next update in %v", updateDuration))
time.Sleep(updateDuration)
for {
updateDuckDns(logger, cfg)
time.Sleep(cfg.UpdateInterval)
}
}
// Call out to DuckDNS to update IP address
//
// Parameters:
// logger - syslog writer to use for logging errors
// cfg - configuration data
//
// Returns:
// error - any errors encountered during the update process
func updateDuckDns(logger *syslog.Writer, cfg *Config) error {
// Grab fields
domain := cfg.Domain
token := cfg.Token
// Update DuckDNS
url := fmt.Sprintf(duckdns_url, domain, token)
duckdns_status, err := fetchUrl(url, true)
if err != nil {
logger.Err(fmt.Sprintf("Error updating DuckDNS: %v", err))
return err
}
status, ip4, ip6, info, err := parseDuckDnsOutput(duckdns_status)
if err != nil {
logger.Err(fmt.Sprintf("Error parsing DuckDNS status: %v", err))
return err
}
// log line
logger.Info(fmt.Sprintf("%s,%s,%s,%s", status, ip4, ip6, info))
return nil
}
// Fetches data from a HTTP URL
//
// Parameters:
// url - the URL to fetch
// insecure - whether to skip TLS verification
//
// Returns:
// The response body as a string or an error if there was one
func fetchUrl(url string, insecure bool) (string, error) {
tr := &http.Transport{}
if insecure {
tr.TLSClientConfig = &tls.Config{InsecureSkipVerify: true}
}
client := &http.Client{Transport: tr, Timeout: 10 * time.Second}
resp, err := client.Get(url)
if err != nil {
return "", err
}
defer resp.Body.Close()
b, err := io.ReadAll(resp.Body)
return string(b), err
}
// Takes a string of splits the four‐line verbose output
//
// Parameters:
//
// output - the verbose output from DuckDNS API
//
// Returns:
//
// status = line 4 ("OK" or "KO")
// ipv4 = first field on line 2
// ipv6 = first field on line 3
// status = line 4 ("UPDATED" or "NOCHANGE")
func parseDuckDnsOutput(output string) (status, ip4, ip6, info string, _ error) {
lines := strings.Split(strings.TrimSpace(output), "\n")
if len(lines) < 4 {
return "", "", "", "", fmt.Errorf("invalid output")
}
status = strings.TrimSpace(lines[0]) // "OK" or "KO"
ip4 = strings.TrimSpace(lines[1]) // IPv4 address [Can be blank]
ip6 = strings.TrimSpace(lines[2]) // IPv6 address [Can be blank]
info = strings.TrimSpace(lines[3]) // “UPDATED” or “NOCHANGE”
return
}
// Load the configuration file
//
// Loads the configuration data from either of the following locations:
// - The home directory (~/duckdns-client.json) [Default]
// - The /etc/duckdns-client/config.json file [Alternative]
//
// Returns:
// Pointer to the Config struct or an error if something went wrong.
func loadConfig() (*Config, error) {
// Formulate path of the config file
homeDir, _ := os.UserHomeDir()
usrConfigPath := filepath.Join(homeDir, ".duckdns-client.json")
etcConfigPath := filepath.Join("/", "etc", "duckdns-client", "config.json")
// Try the home directory first
if _, err := os.Stat(usrConfigPath); err == nil {
return parseConfig(usrConfigPath)
}
// Fall back on system config
if _, err := os.Stat(etcConfigPath); err == nil {
return parseConfig(etcConfigPath)
}
return nil, errors.New("config files do not exist")
}
// Helper function to loadConfig
//
// Parameters:
// path - Path to the config file.
//
// Returns:
//
// Pointer to the Config struct or an error if something went wrong.
func parseConfig(path string) (*Config, error) {
data, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("reading %s: %w", path, err)
}
var cfg Config
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("parsing %s: %w", path, err)
}
if cfg.UpdateInterval == 0 {
cfg.UpdateInterval = 15 * time.Minute
}
return &cfg, nil
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment