11 Commits

Author SHA1 Message Date
mxmlndml
7c8cc275ed fix update and add initialization 2024-03-22 17:59:13 +01:00
mxmlndml
4744ef5922 fix dockerfile 2024-03-22 13:16:43 +01:00
mxmlndml
cad4064f3e rewrite in go and add support for ipv6 2024-03-22 13:08:43 +01:00
mxmlndml
352e9e1874 add image to readme 2023-08-28 22:27:52 +02:00
mxmlndml
7df6f5ebe2 edit readme 2023-08-28 21:38:16 +02:00
mxmlndml
325bc905f0 1.0.2 2023-08-28 20:17:49 +02:00
mxmlndml
4995a08260 detect dns records to patch properly 2023-08-28 20:17:42 +02:00
mxmlndml
6695db9d31 1.0.1 2023-08-28 19:39:24 +02:00
mxmlndml
64de314ea5 fix node script 2023-08-28 19:37:47 +02:00
mxmlndml
869d0d4e97 bump version 2023-08-28 19:12:52 +02:00
mxmlndml
027d499875 support multiple domains 2023-08-28 19:11:35 +02:00
19 changed files with 474 additions and 823 deletions

View File

@@ -1,5 +1,3 @@
node_modules
.git
.gitignore
*.md
dist

2
.env.template Normal file
View File

@@ -0,0 +1,2 @@
API_KEY=123
ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353

137
.gitignore vendored
View File

@@ -1,130 +1,21 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
.pnpm-debug.log*
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Test binary, built with `go test -c`
*.test
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Dependency directories (remove the comment below to include it)
# vendor/
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Microbundle cache
.rpt2_cache/
.rts2_cache_cjs/
.rts2_cache_es/
.rts2_cache_umd/
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# Go workspace file
go.work
# dotenv environment variable files
.env
.env.development.local
.env.test.local
.env.production.local
.env.local
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v2
.yarn/cache
.yarn/unplugged
.yarn/build-state.yml
.yarn/install-state.gz
.pnp.*

View File

@@ -1,18 +1,12 @@
FROM node:slim AS base
ENV PNPM_HOME="/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM golang:1.22
FROM base as prod-deps
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
WORKDIR /usr/src/app
FROM base AS build
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile
RUN pnpm run build
# pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
# COPY go.mod go.sum ./
# RUN go mod download && go mod verify
FROM base
COPY --from=prod-deps /app/node_modules /app/node_modules
COPY --from=build /app/dist /app/dist
CMD [ "pnpm", "start" ]
COPY . .
RUN go build -v -o /usr/local/bin/app ./...
CMD ["app"]

View File

@@ -1,6 +1,6 @@
MIT License
Copyright (c) 2023 mxmlndml
Copyright (c) 2024 mxmlndml
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,6 +1,60 @@
# Cloudflare Dynamic DNS
# Dynamic DNS Updates with Cloudflare
This Docker container provides a simple and efficient solution for dynamic DNS
updates using Cloudflare DNS. It allows you to automatically update your DNS
records in Cloudflare at specified intervals, ensuring that your services are
always accessible through a domain name.
![cloudflare-dynamic-dns](https://github.com/mxmlndml/cloudflare-dynamic-dns/assets/42516330/d1faa020-e730-4f53-9706-4fe9e9a7bd41)
This Docker container offers a straightforward and efficient solution for
automating dynamic DNS updates using the Cloudflare DNS service. It empowers you
to effortlessly update your DNS records in Cloudflare at predefined intervals,
guaranteeing that your services are consistently accessible through a domain
name.
## Prerequisites
Before you can use this Docker container, ensure you meet the following
prerequisites:
- Docker: [Docker installation guide](https://docs.docker.com/get-docker/)
- Cloudflare DNS:
[Cloudflare zone setups guide](https://developers.cloudflare.com/dns/zone-setups/)
## Installation
This script runs as a Docker container, which means installation is as simple as
pulling the pre-built Docker container and running it with the necessary
environment variables
```sh
docker run -d -e API_KEY=123 -e ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353 -e DOMAIN_NAMES=dyndns.example.com,example.com --restart=always mxmlndml/cloudflare-dynamic-dns
```
Alternatively you can copy the `docker-compose.yml` from this repository into an
empty directory of your machine, edit the environment variables and start the
container with `docker compose`
```sh
docker compose up -d
```
## Configuration
You can configure this Docker container using environment variables. Here's a
breakdown of the available configuration variables:
- **`API_KEY`** _required_
\
Cloudflare API token with `Zone Settings:Read`, `Zone:Read` and `DNS:Edit`
permissions
- **`ZONE_ID`** _required_
\
Zone ID of your website (in the right sidebar on the overview page of your
site)
- **`DOMAIN_NAMES`** _required_
\
List of DNS A records that should store your public IP address delimited by a
comma (and only a comma)
- **`INTERVAL`** _defaults to `5`_
\
Time interval in minutes between DNS updates
- **`LOG_LEVEL`** _defaults to `INFO`_
\
Logging level for the container, either `DEBUG`, `INFO`, `WARN` or `ERROR`

141
cloudflare.go Normal file
View File

@@ -0,0 +1,141 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
)
type dnsRecord struct {
id string
content string
}
type DNSRecords struct {
name string
a dnsRecord
aaaa dnsRecord
}
func setAuthHeader(req *http.Request, apiKey string) {
authHeader := fmt.Sprint("bearer ", apiKey)
req.Header.Add("Authorization", authHeader)
}
func GetDNSRecord(zoneID string, domainName string, apiKey string) DNSRecords {
dnsRecords := DNSRecords{
name: domainName,
}
url := fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%s/dns_records?name=%s", zoneID, domainName)
req, err := http.NewRequest("GET", url, nil)
if err != nil {
log.Panic("Error creating the request: ", err)
}
setAuthHeader(req, apiKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Panic("Error loading the response: ", err)
}
defer resp.Body.Close()
var data struct {
Success bool
Errors []struct {
Message string
}
Result []struct {
ID string
Content string
Type string
}
}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
log.Panic("Error parsing JSON: ", err)
}
if !data.Success {
msg := ""
for i, err := range data.Errors {
if i != 0 {
msg += ", "
}
msg += err.Message
}
log.Panic("Server responded with error: ", msg)
}
for _, record := range data.Result {
switch record.Type {
case "A":
dnsRecords.a = dnsRecord{id: record.ID, content: record.Content}
case "AAAA":
dnsRecords.aaaa = dnsRecord{id: record.ID, content: record.Content}
}
}
return dnsRecords
}
type DNSRecordBody struct {
Content string
Name string
Type string
}
func UpdateDNSRecord(zoneID string, dnsRecordID string, apiKey string, body DNSRecordBody) {
var method string
var url string
if dnsRecordID == "" {
method = http.MethodPost
url = fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%v/dns_records", zoneID)
} else {
method = http.MethodPatch
url = fmt.Sprintf("https://api.cloudflare.com/client/v4/zones/%v/dns_records/%v", zoneID, dnsRecordID)
}
encodedBody, err := json.Marshal(&body)
if err != nil {
log.Panic("Error parsing the json body: ", err)
}
req, err := http.NewRequest(method, url, bytes.NewReader(encodedBody))
if err != nil {
log.Panic("Error creating the request: ", err)
}
setAuthHeader(req, apiKey)
req.Header.Add("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
log.Panic("Error loading the response: ", err)
}
defer resp.Body.Close()
var data struct {
Success bool
Errors []struct {
Message string
}
}
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
log.Fatal("Error parsing JSON: ", err)
}
if !data.Success {
msg := ""
for i, err := range data.Errors {
if i != 0 {
msg += ", "
}
msg += err.Message
}
log.Panic("Server responded with error: ", msg)
}
}

View File

@@ -1,10 +1,9 @@
services:
cloudflare-dynamic-dns:
image: mxmlndml/cloudflare-dynamic-dns:latest
restart: always
environment:
- API_KEY=123
- ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353
- DOMAIN_NAME=dyndns.example.com
- INTERVAL=5
- LOG_LEVEL=INFO
- "API_KEY=${API_KEY}"
- "ZONE_ID=${ZONE_ID}"
- "DOMAIN_NAMES=example.com,dyndns.example.com"
# - "RECORD_TYPES=A"
# - "INTERVAL=5"

84
env.go Normal file
View File

@@ -0,0 +1,84 @@
package main
import (
"log"
"os"
"strconv"
"strings"
)
func GetAPIKey() string {
value, isSet := os.LookupEnv("API_KEY")
if !isSet {
log.Panic("Missing environment variable 'API_KEY'")
}
return value
}
func GetZoneID() string {
value, isSet := os.LookupEnv("ZONE_ID")
if !isSet {
log.Panic("Missing environment variable 'ZONE_ID'")
}
return value
}
func GetDomainNames() []string {
value, isSet := os.LookupEnv("DOMAIN_NAMES")
if !isSet {
log.Panic("Missing environment variable 'DOMAIN_NAMES'")
}
return strings.Split(value, ",")
}
func UseIPv4() bool {
value, isSet := os.LookupEnv("RECORD_TYPES")
if !isSet {
return true
}
switch value {
case "A", "*":
return true
case "AAAA":
return false
default:
log.Panicf("Unrecognized value '%v' for 'RECORD_TYPES'", value)
return false
}
}
func UseIPv6() bool {
value, isSet := os.LookupEnv("RECORD_TYPES")
if !isSet {
return false
}
switch value {
case "AAAA", "*":
return true
case "A":
return false
default:
log.Panicf("Unrecognized value '%v' for 'RECORD_TYPES'", value)
return false
}
}
func GetInterval() int {
value, isSet := os.LookupEnv("INTERVAL")
if !isSet {
return 5
}
interval, err := strconv.Atoi(value)
if err != nil {
log.Panic("Error converting 'INTERVAL' to integer: ", err)
}
return interval
}

3
go.mod Normal file
View File

@@ -0,0 +1,3 @@
module github.com/mxmlndml/cloudflare-dynamic-dns
go 1.22.0

132
main.go Normal file
View File

@@ -0,0 +1,132 @@
package main
import (
"fmt"
"log"
"strings"
"sync"
"time"
)
type publicIP struct {
v4 string
v6 string
}
func getPublicIP() publicIP {
var publicIP publicIP
var wg sync.WaitGroup
if UseIPv4() {
wg.Add(1)
go func() {
publicIP.v4 = GetPublicIP(4)
wg.Done()
}()
}
if UseIPv6() {
wg.Add(1)
go func() {
publicIP.v6 = GetPublicIP(6)
wg.Done()
}()
}
wg.Wait()
return publicIP
}
func getDNSRecords() []DNSRecords {
apiKey := GetAPIKey()
zoneID := GetZoneID()
domainNames := GetDomainNames()
ch := make(chan DNSRecords, len(domainNames))
defer close(ch)
for _, domainName := range domainNames {
go func() {
ch <- GetDNSRecord(zoneID, domainName, apiKey)
}()
}
var dnsRecords []DNSRecords
for i := 0; i < len(domainNames); i++ {
dnsRecord := <-ch
dnsRecords = append(dnsRecords, dnsRecord)
}
return dnsRecords
}
func initialize() {
fmt.Println(" _______ _______ ___ _ ___ _ ______")
fmt.Println(" / ___/ /__ __ _____/ / _/ /__ ________ / _ \\__ _____ ___ ___ _ (_)___ / _ \\/ |/ / __/")
fmt.Println("/ /__/ / _ \\/ // / _ / _/ / _ `/ __/ -_) / // / // / _ \\/ _ `/ ' \\/ / __/ / // / /\\ \\ ")
fmt.Println("\\___/_/\\___/\\_,_/\\_,_/_//_/\\_,_/_/ \\__/ /____/\\_, /_//_/\\_,_/_/_/_/_/\\__/ /____/_/|_/___/ ")
fmt.Println(" /___/ ")
var recordType string
if UseIPv4() && UseIPv6() {
recordType = "A and AAAA"
} else if UseIPv4() {
recordType = "A"
} else if UseIPv6() {
recordType = "AAAA"
}
domainNames := strings.Join(GetDomainNames(), ", ")
interval := GetInterval()
fmt.Printf("Updating %v records of %v every %v minutes\n\n", recordType, domainNames, interval)
}
func main() {
initialize()
for {
var publicIP publicIP
var dnsRecords []DNSRecords
var wg sync.WaitGroup
// concurrently fetch public ip and dns records
wg.Add(2)
go func() {
publicIP = getPublicIP()
wg.Done()
}()
go func() {
dnsRecords = getDNSRecords()
wg.Done()
}()
wg.Wait()
// concurrently create/update dns entries if their content is not current public ip
apiKey := GetAPIKey()
zoneID := GetZoneID()
for _, dnsRecord := range dnsRecords {
if UseIPv4() && publicIP.v4 != dnsRecord.a.content {
wg.Add(1)
go func() {
UpdateDNSRecord(zoneID, dnsRecord.a.id, apiKey, DNSRecordBody{Type: "A", Name: dnsRecord.name, Content: publicIP.v4})
log.Printf("Set DNS A record %v to %v", dnsRecord.name, publicIP.v4)
wg.Done()
}()
}
if UseIPv6() && publicIP.v6 != dnsRecord.aaaa.content {
wg.Add(1)
go func() {
UpdateDNSRecord(zoneID, dnsRecord.aaaa.id, apiKey, DNSRecordBody{Type: "AAAA", Name: dnsRecord.name, Content: publicIP.v6})
log.Printf("Set DNS AAAA record %v to %v", dnsRecord.name, publicIP.v6)
wg.Done()
}()
}
}
wg.Wait()
time.Sleep(time.Duration(GetInterval()) * time.Minute)
}
}

View File

@@ -1,18 +0,0 @@
{
"name": "cloudflare-dynamic-dns",
"version": "0.1.0",
"description": "",
"main": "dist/index.js",
"scripts": {
"build": "rimraf ./dist && tsc",
"start": "pnpm build && node ."
},
"keywords": [],
"author": "",
"license": "MIT",
"devDependencies": {
"@types/node": "^20.2.5",
"rimraf": "^5.0.1",
"typescript": "^5.0.4"
}
}

258
pnpm-lock.yaml generated
View File

@@ -1,258 +0,0 @@
lockfileVersion: '6.0'
devDependencies:
'@types/node':
specifier: ^20.2.5
version: 20.2.5
rimraf:
specifier: ^5.0.1
version: 5.0.1
typescript:
specifier: ^5.0.4
version: 5.0.4
packages:
/@isaacs/cliui@8.0.2:
resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==}
engines: {node: '>=12'}
dependencies:
string-width: 5.1.2
string-width-cjs: /string-width@4.2.3
strip-ansi: 7.1.0
strip-ansi-cjs: /strip-ansi@6.0.1
wrap-ansi: 8.1.0
wrap-ansi-cjs: /wrap-ansi@7.0.0
dev: true
/@pkgjs/parseargs@0.11.0:
resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==}
engines: {node: '>=14'}
requiresBuild: true
dev: true
optional: true
/@types/node@20.2.5:
resolution: {integrity: sha512-JJulVEQXmiY9Px5axXHeYGLSjhkZEnD+MDPDGbCbIAbMslkKwmygtZFy1X6s/075Yo94sf8GuSlFfPzysQrWZQ==}
dev: true
/ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
dev: true
/ansi-regex@6.0.1:
resolution: {integrity: sha512-n5M855fKb2SsfMIiFFoVrABHJC8QtHwVx+mHWP3QcEqBHYienj5dHSgjbxtC0WEZXYt4wcD6zrQElDPhFuZgfA==}
engines: {node: '>=12'}
dev: true
/ansi-styles@4.3.0:
resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==}
engines: {node: '>=8'}
dependencies:
color-convert: 2.0.1
dev: true
/ansi-styles@6.2.1:
resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==}
engines: {node: '>=12'}
dev: true
/balanced-match@1.0.2:
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
dev: true
/brace-expansion@2.0.1:
resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==}
dependencies:
balanced-match: 1.0.2
dev: true
/color-convert@2.0.1:
resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==}
engines: {node: '>=7.0.0'}
dependencies:
color-name: 1.1.4
dev: true
/color-name@1.1.4:
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
dev: true
/cross-spawn@7.0.3:
resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==}
engines: {node: '>= 8'}
dependencies:
path-key: 3.1.1
shebang-command: 2.0.0
which: 2.0.2
dev: true
/eastasianwidth@0.2.0:
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
dev: true
/emoji-regex@8.0.0:
resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==}
dev: true
/emoji-regex@9.2.2:
resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==}
dev: true
/foreground-child@3.1.1:
resolution: {integrity: sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==}
engines: {node: '>=14'}
dependencies:
cross-spawn: 7.0.3
signal-exit: 4.0.2
dev: true
/glob@10.2.6:
resolution: {integrity: sha512-U/rnDpXJGF414QQQZv5uVsabTVxMSwzS5CH0p3DRCIV6ownl4f7PzGnkGmvlum2wB+9RlJWJZ6ACU1INnBqiPA==}
engines: {node: '>=16 || 14 >=14.17'}
hasBin: true
dependencies:
foreground-child: 3.1.1
jackspeak: 2.2.1
minimatch: 9.0.1
minipass: 6.0.2
path-scurry: 1.9.2
dev: true
/is-fullwidth-code-point@3.0.0:
resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==}
engines: {node: '>=8'}
dev: true
/isexe@2.0.0:
resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==}
dev: true
/jackspeak@2.2.1:
resolution: {integrity: sha512-MXbxovZ/Pm42f6cDIDkl3xpwv1AGwObKwfmjs2nQePiy85tP3fatofl3FC1aBsOtP/6fq5SbtgHwWcMsLP+bDw==}
engines: {node: '>=14'}
dependencies:
'@isaacs/cliui': 8.0.2
optionalDependencies:
'@pkgjs/parseargs': 0.11.0
dev: true
/lru-cache@9.1.1:
resolution: {integrity: sha512-65/Jky17UwSb0BuB9V+MyDpsOtXKmYwzhyl+cOa9XUiI4uV2Ouy/2voFP3+al0BjZbJgMBD8FojMpAf+Z+qn4A==}
engines: {node: 14 || >=16.14}
dev: true
/minimatch@9.0.1:
resolution: {integrity: sha512-0jWhJpD/MdhPXwPuiRkCbfYfSKp2qnn2eOc279qI7f+osl/l+prKSrvhg157zSYvx/1nmgn2NqdT6k2Z7zSH9w==}
engines: {node: '>=16 || 14 >=14.17'}
dependencies:
brace-expansion: 2.0.1
dev: true
/minipass@6.0.2:
resolution: {integrity: sha512-MzWSV5nYVT7mVyWCwn2o7JH13w2TBRmmSqSRCKzTw+lmft9X4z+3wjvs06Tzijo5z4W/kahUCDpRXTF+ZrmF/w==}
engines: {node: '>=16 || 14 >=14.17'}
dev: true
/path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
dev: true
/path-scurry@1.9.2:
resolution: {integrity: sha512-qSDLy2aGFPm8i4rsbHd4MNyTcrzHFsLQykrtbuGRknZZCBBVXSv2tSCDN2Cg6Rt/GFRw8GoW9y9Ecw5rIPG1sg==}
engines: {node: '>=16 || 14 >=14.17'}
dependencies:
lru-cache: 9.1.1
minipass: 6.0.2
dev: true
/rimraf@5.0.1:
resolution: {integrity: sha512-OfFZdwtd3lZ+XZzYP/6gTACubwFcHdLRqS9UX3UwpU2dnGQYkPFISRwvM3w9IiB2w7bW5qGo/uAwE4SmXXSKvg==}
engines: {node: '>=14'}
hasBin: true
dependencies:
glob: 10.2.6
dev: true
/shebang-command@2.0.0:
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
engines: {node: '>=8'}
dependencies:
shebang-regex: 3.0.0
dev: true
/shebang-regex@3.0.0:
resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==}
engines: {node: '>=8'}
dev: true
/signal-exit@4.0.2:
resolution: {integrity: sha512-MY2/qGx4enyjprQnFaZsHib3Yadh3IXyV2C321GY0pjGfVBu4un0uDJkwgdxqO+Rdx8JMT8IfJIRwbYVz3Ob3Q==}
engines: {node: '>=14'}
dev: true
/string-width@4.2.3:
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
engines: {node: '>=8'}
dependencies:
emoji-regex: 8.0.0
is-fullwidth-code-point: 3.0.0
strip-ansi: 6.0.1
dev: true
/string-width@5.1.2:
resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==}
engines: {node: '>=12'}
dependencies:
eastasianwidth: 0.2.0
emoji-regex: 9.2.2
strip-ansi: 7.1.0
dev: true
/strip-ansi@6.0.1:
resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==}
engines: {node: '>=8'}
dependencies:
ansi-regex: 5.0.1
dev: true
/strip-ansi@7.1.0:
resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==}
engines: {node: '>=12'}
dependencies:
ansi-regex: 6.0.1
dev: true
/typescript@5.0.4:
resolution: {integrity: sha512-cW9T5W9xY37cc+jfEnaUvX91foxtHkza3Nw3wkoF4sSlKn0MONdkdEndig/qPBWXNkmplh3NzayQzCiHM4/hqw==}
engines: {node: '>=12.20'}
hasBin: true
dev: true
/which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
hasBin: true
dependencies:
isexe: 2.0.0
dev: true
/wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
dev: true
/wrap-ansi@8.1.0:
resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==}
engines: {node: '>=12'}
dependencies:
ansi-styles: 6.2.1
string-width: 5.1.2
strip-ansi: 7.1.0
dev: true

24
public_ip.go Normal file
View File

@@ -0,0 +1,24 @@
package main
import (
"fmt"
"io"
"log"
"net/http"
"strings"
)
func GetPublicIP(version int8) string {
url := fmt.Sprintf("https://ipv%d.icanhazip.com", version)
resp, err := http.Get(url)
if err != nil {
log.Panic("Failed to get public IP: ", err)
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
return strings.TrimSpace(string(body))
}

View File

@@ -1,80 +0,0 @@
type CFResponse = {
result: {
content: string;
name: string;
type: string;
id: string;
}[];
errors: {
code: number;
message: string;
}[];
success: boolean;
};
const getDnsRecord = async (
zoneIdentifier: string,
name: string,
type: string,
apiKey: string,
): Promise<{ content: string; id: string }> => {
const url =
`https://api.cloudflare.com/client/v4/zones/${zoneIdentifier}/dns_records?name=${name}&type=${type}`;
const headers = {
Authorization: `bearer ${apiKey}`,
};
const response = await fetch(url, { headers });
const json: CFResponse = await response.json();
if (json.success) {
return (({ content, id }) => ({ content, id }))(json.result[0]);
}
const error = json.errors.reduce(
(message, error) => `${message}${error.message}. `,
"",
);
throw new Error(
`failed to get dns ${type.toLowerCase()} record '${name}'. ${error}`,
);
};
const patchDnsRecord = async (
zoneIdentifier: string,
identifier: string,
apiKey: string,
content: string,
name: string,
type: string,
) => {
const url =
`https://api.cloudflare.com/client/v4/zones/${zoneIdentifier}/dns_records/${identifier}`;
const method = "PATCH";
const headers = {
Authorization: `bearer ${apiKey}`,
"Content-Type": "application/json",
};
const body = JSON.stringify({
content,
name,
type,
});
const response = await fetch(url, { method, headers, body });
const json: CFResponse = await response.json();
if (json.success) {
return;
}
const error = json.errors.reduce(
(message, error) => `${message}${error.message}. `,
"",
);
throw new Error(
`failed to patch dns ${type.toLowerCase} record '${name}'. ${error}`,
);
};
export { getDnsRecord, patchDnsRecord };

View File

@@ -1,98 +0,0 @@
import { Resolver } from "dns/promises";
import * as log from "./log";
const OPEN_DNS = {
RESOLVER: "resolver1.opendns.com",
MYIP: "myip.opendns.com",
};
const HTTPS_URLS = [
"https://ipv4.icanhazip.com",
"https://ifconfig.me/ip",
"https://myexternalip.com/raw",
"https://ipecho.net/plain",
];
let dnsServers: string[] = [];
// get public ipv4 address via dns
const dns = async (): Promise<string> => {
const resolver = new Resolver();
// set resolver to opendns
if (dnsServers.length === 0) {
// cache dns server ip
dnsServers = await resolver.resolve4(OPEN_DNS.RESOLVER);
log.debug(`cached resolver ip address '${dnsServers[0]}'`);
}
resolver.setServers(dnsServers);
// get public ip via opendns dns lookup
const [publicIp] = await resolver.resolve4(OPEN_DNS.MYIP);
log.debug(`determined public ip address '${publicIp}' via dns`);
return publicIp;
};
const https = async (): Promise<string> => {
const messages: string[] = [];
const requests = HTTPS_URLS.map(async (url: string): Promise<Response> => {
try {
const response = await fetch(url);
if (response.ok) {
return response;
}
throw new Error(response.statusText);
} catch (error) {
const message =
`failed to fetch public ip address via https from '${url}'`;
messages.push(message);
throw new Error(message);
}
});
try {
const response = await Promise.any(requests);
const publicIp = (await response.text()).replace("\n", "");
log.debug(
`determined public ip address '${publicIp}' via https using '${response.url}'`,
);
return publicIp;
} catch (error) {
messages.forEach((message) => log.warn(message));
throw new Error((error as Error).message);
}
};
const getPublicIp = async () => {
let ip = "";
try {
log.debug("determine public ip address via dns");
ip = await dns();
} catch (error) {
if (dnsServers.length === 0) {
log.warn(`dns resolution of '${OPEN_DNS.RESOLVER}' timed out`);
} else {
log.warn(
`dns resolution of '${OPEN_DNS.MYIP}' via '${dnsServers[0]}' timed out`,
);
dnsServers = [];
log.debug("reset cached dns servers");
}
log.debug("fall back to https");
try {
log.debug("determine public ip address via https");
ip = await https();
} catch (error) {
throw new Error(
"failed to determine public ip address via dns and https",
);
}
}
return ip;
};
export default getPublicIp;

View File

@@ -1,58 +0,0 @@
import { getDnsRecord, patchDnsRecord } from "./cloudflare";
import getPublicIp from "./getPublicIp";
import * as log from "./log";
const { ZONE_ID, DOMAIN_NAME, API_KEY, INTERVAL } = process.env;
if (ZONE_ID === undefined) {
log.error("could not access environment variable 'ZONE_ID'");
}
if (DOMAIN_NAME === undefined) {
log.error("could not access environment variable 'DOMAIN_NAME'");
}
if (API_KEY === undefined) {
log.error("could not access environment variable 'API_KEY'");
}
if (
ZONE_ID === undefined || DOMAIN_NAME === undefined || API_KEY === undefined
) {
process.exit(1);
}
const dynamicDns = async () => {
try {
const [publicIp, dnsRecord] = await Promise.all([
getPublicIp(),
getDnsRecord(ZONE_ID, DOMAIN_NAME, "A", API_KEY),
]);
if (publicIp === dnsRecord.content) {
log.info(`public ip address remained at '${publicIp}', no patch needed`);
log.info(`checking again in ${INTERVAL} minutes\n`);
return;
}
log.info(
`public ip address changed from '${dnsRecord.content}' to '${publicIp}'`,
);
await patchDnsRecord(
ZONE_ID,
dnsRecord.id,
API_KEY,
publicIp,
DOMAIN_NAME,
"A",
);
log.info("patched dns entry");
log.info(`checking again in ${INTERVAL} minutes\n`);
} catch (error) {
log.error((error as Error).message);
log.info(`retrying in ${INTERVAL} minutes\n`);
}
};
dynamicDns();
setInterval(
dynamicDns,
Number.parseInt(INTERVAL === undefined ? "5" : INTERVAL) * 60 * 1000,
);

View File

@@ -1,50 +0,0 @@
const STYLES = {
RESET: "\x1b[0m",
DEBUG: "\x1b[37m",
INFO: "\x1b[34m",
WARN: "\x1b[33m",
ERROR: "\x1b[31m",
};
let LOG_LEVEL = process.env.LOG_LEVEL || "INFO";
if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(LOG_LEVEL)) {
console.warn(
`${STYLES.WARN}[WARN]\tunknown log level '${LOG_LEVEL}', proceeding with log level 'INFO'${STYLES.RESET}`,
);
LOG_LEVEL = "INFO";
}
const debug = (...data: string[]) => {
if (!["DEBUG"].includes(LOG_LEVEL)) {
return;
}
const message = `${STYLES.DEBUG}[DEBUG]\t${data.join(" ")}${STYLES.RESET}`;
console.debug(message);
};
const info = (...data: string[]) => {
if (!["DEBUG", "INFO"].includes(LOG_LEVEL)) {
return;
}
const message = `${STYLES.INFO}[INFO]\t${data.join(" ")}${STYLES.RESET}`;
console.info(message);
};
const warn = (...data: string[]) => {
if (!["DEBUG", "INFO", "WARN"].includes(LOG_LEVEL)) {
return;
}
const message = `${STYLES.WARN}[WARN]\t${data.join(" ")}${STYLES.RESET}`;
console.warn(message);
};
const error = (...data: string[]) => {
if (!["DEBUG", "INFO", "WARN", "ERROR"].includes(LOG_LEVEL)) {
return;
}
const message = `${STYLES.ERROR}[ERROR]\t${data.join(" ")}${STYLES.RESET}`;
console.error(message);
};
export { debug, error, info, warn };

View File

@@ -1,109 +0,0 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for legacy experimental decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "commonjs", /* Specify what module code is generated. */
"rootDir": "./src", /* Specify the root folder within your source files. */
// "moduleResolution": "node10", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
// "types": [], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "allowImportingTsExtensions": true, /* Allow imports to include TypeScript file extensions. Requires '--moduleResolution bundler' and either '--noEmit' or '--emitDeclarationOnly' to be set. */
// "resolvePackageJsonExports": true, /* Use the package.json 'exports' field when resolving package imports. */
// "resolvePackageJsonImports": true, /* Use the package.json 'imports' field when resolving imports. */
// "customConditions": [], /* Conditions to set in addition to the resolver-specific defaults when resolving imports. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "allowArbitraryExtensions": true, /* Enable importing files with any extension, provided a declaration file is present. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
"outDir": "./dist", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "verbatimModuleSyntax": true, /* Do not transform or elide any imports or exports not marked as type-only, ensuring they are written in the output file's format based on the 'module' setting. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}