rewrite in go and add support for ipv6

This commit is contained in:
mxmlndml
2024-03-22 13:08:43 +01:00
parent 352e9e1874
commit cad4064f3e
18 changed files with 381 additions and 854 deletions

View File

@@ -1,5 +1,3 @@
node_modules
.git .git
.gitignore .gitignore
*.md *.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 # Binaries for programs and plugins
logs *.exe
*.log *.exe~
npm-debug.log* *.dll
yarn-debug.log* *.so
yarn-error.log* *.dylib
lerna-debug.log*
.pnpm-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html) # Test binary, built with `go test -c`
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json *.test
# Runtime data # Output of the go coverage tool, specifically when used with LiteIDE
pids *.out
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover # Dependency directories (remove the comment below to include it)
lib-cov # vendor/
# Coverage directory used by tools like istanbul # Go workspace file
coverage go.work
*.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
# dotenv environment variable files # dotenv environment variable files
.env .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 FROM golang:1.22
ENV PNPM_HOME="/pnpm"
ENV PATH="${PNPM_HOME}:${PATH}"
RUN corepack enable
COPY . /app
WORKDIR /app
FROM base as prod-deps WORKDIR /usr/src/app
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --prod --frozen-lockfile
FROM base AS build # pre-copy/cache go.mod for pre-downloading dependencies and only redownloading them in subsequent builds if they change
RUN --mount=type=cache,id=pnpm,target=/pnpm/store pnpm install --frozen-lockfile COPY go.mod go.sum ./
RUN pnpm run build RUN go mod download && go mod verify
FROM base COPY . .
COPY --from=prod-deps /app/node_modules /app/node_modules RUN go build -v -o /usr/local/bin/app ./...
COPY --from=build /app/dist /app/dist
CMD [ "pnpm", "start" ] CMD ["app"]

View File

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

134
cloudflare.go Normal file
View File

@@ -0,0 +1,134 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"log"
"net/http"
)
func setAuthHeader(req *http.Request, apiKey string) {
authHeader := fmt.Sprint("bearer ", apiKey)
req.Header.Add("Authorization", authHeader)
}
type cloudflareResponse struct {
Success bool
Result []struct {
ID string
Content string
Type string
}
Errors []struct {
Message string
}
}
func checkServerErrors(data *cloudflareResponse) {
if data.Success {
return
}
msg := ""
for i, err := range data.Errors {
if i != 0 {
msg += ", "
}
msg += err.Message
}
log.Panic("Server responded with error: ", msg)
}
type dnsRecord struct {
id string
content string
}
type DNSRecords struct {
name string
a dnsRecord
aaaa dnsRecord
}
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 cloudflareResponse
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
log.Panic("Error parsing JSON: ", err)
}
checkServerErrors(&data)
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 cloudflareResponse
err = json.NewDecoder(resp.Body).Decode(&data)
if err != nil {
log.Fatal("Error parsing JSON: ", err)
}
checkServerErrors(&data)
}

View File

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

105
main.go Normal file
View File

@@ -0,0 +1,105 @@
package main
import (
"log"
"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 main() {
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 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 record %v to %v", dnsRecord.name, publicIP.v6)
wg.Done()
}()
}
}
wg.Wait()
time.Sleep(time.Duration(GetInterval()) * time.Minute)
}
}

View File

@@ -1,19 +0,0 @@
{
"name": "cloudflare-dynamic-dns",
"version": "1.0.2",
"description": "",
"main": "dist/index.js",
"scripts": {
"start": "node .",
"dev": "tsc -w",
"build": "rimraf ./dist && tsc"
},
"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,108 +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; name: 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, name, id }) => ({ content, name, 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 getDnsRecords = async (
zoneIdentifier: string,
names: string[],
type: string,
apiKey: string,
): Promise<{ content: string; name: string; id: string }[]> =>
await Promise.all(
names.map(async (name) => getDnsRecord(zoneIdentifier, name, type, apiKey)),
);
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}`,
);
};
const patchDnsRecords = async (
dnsRecords: { content: string; name: string; id: string }[],
zoneIdentifier: string,
apiKey: string,
content: string,
type: string,
) =>
dnsRecords.forEach(async (dnsRecord) =>
await patchDnsRecord(
zoneIdentifier,
dnsRecord.id,
apiKey,
content,
dnsRecord.name,
type,
)
);
export { getDnsRecord, getDnsRecords, patchDnsRecord, patchDnsRecords };

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,65 +0,0 @@
import { getDnsRecords, patchDnsRecords } from "./cloudflare";
import getPublicIp from "./getPublicIp";
import * as log from "./log";
const { ZONE_ID, DOMAIN_NAMES, API_KEY } = process.env;
const INTERVAL = process.env.INTERVAL ?? "5";
if (ZONE_ID === undefined) {
log.error("could not access environment variable 'ZONE_ID'");
}
if (DOMAIN_NAMES === undefined) {
log.error("could not access environment variable 'DOMAIN_NAMES'");
}
if (API_KEY === undefined) {
log.error("could not access environment variable 'API_KEY'");
}
if (
ZONE_ID === undefined || DOMAIN_NAMES === undefined || API_KEY === undefined
) {
process.exit(1);
}
const dynamicDns = async () => {
const domainNames = DOMAIN_NAMES.split(",");
try {
const [publicIp, dnsRecords] = await Promise.all([
getPublicIp(),
getDnsRecords(ZONE_ID, domainNames, "A", API_KEY),
]);
const dnsRecordsToPatch = dnsRecords.filter((dnsRecord) =>
dnsRecord.content !== publicIp
);
if (dnsRecordsToPatch.length === 0) {
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 '${
dnsRecordsToPatch[0].content
}' to '${publicIp}'`,
);
await patchDnsRecords(
dnsRecordsToPatch,
ZONE_ID,
API_KEY,
publicIp,
"A",
);
log.info("patched dns entries");
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) * 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. */
}
}