From cad4064f3eca7d2c08ce9a5f1c1b16c6b8f44757 Mon Sep 17 00:00:00 2001 From: mxmlndml <42516330+mxmlndml@users.noreply.github.com> Date: Fri, 22 Mar 2024 13:08:43 +0100 Subject: [PATCH] rewrite in go and add support for ipv6 --- .dockerignore | 2 - .env.template | 2 + .gitignore | 137 +++--------------------- Dockerfile | 24 ++--- LICENSE | 2 +- cloudflare.go | 134 +++++++++++++++++++++++ docker-compose.yml | 11 +- env.go | 84 +++++++++++++++ go.mod | 3 + main.go | 105 ++++++++++++++++++ package.json | 19 ---- pnpm-lock.yaml | 258 --------------------------------------------- public_ip.go | 24 +++++ src/cloudflare.ts | 108 ------------------- src/getPublicIp.ts | 98 ----------------- src/index.ts | 65 ------------ src/log.ts | 50 --------- tsconfig.json | 109 ------------------- 18 files changed, 381 insertions(+), 854 deletions(-) create mode 100644 .env.template create mode 100644 cloudflare.go create mode 100644 env.go create mode 100644 go.mod create mode 100644 main.go delete mode 100644 package.json delete mode 100644 pnpm-lock.yaml create mode 100644 public_ip.go delete mode 100644 src/cloudflare.ts delete mode 100644 src/getPublicIp.ts delete mode 100644 src/index.ts delete mode 100644 src/log.ts delete mode 100644 tsconfig.json diff --git a/.dockerignore b/.dockerignore index 958b26c..59b1bea 100644 --- a/.dockerignore +++ b/.dockerignore @@ -1,5 +1,3 @@ -node_modules .git .gitignore *.md -dist \ No newline at end of file diff --git a/.env.template b/.env.template new file mode 100644 index 0000000..4181f73 --- /dev/null +++ b/.env.template @@ -0,0 +1,2 @@ +API_KEY=123 +ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353 diff --git a/.gitignore b/.gitignore index c6bba59..0a29a9e 100644 --- a/.gitignore +++ b/.gitignore @@ -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.* diff --git a/Dockerfile b/Dockerfile index 0377f05..f5a0241 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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" ] \ No newline at end of file +COPY . . +RUN go build -v -o /usr/local/bin/app ./... + +CMD ["app"] diff --git a/LICENSE b/LICENSE index c44db82..509364c 100644 --- a/LICENSE +++ b/LICENSE @@ -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 diff --git a/cloudflare.go b/cloudflare.go new file mode 100644 index 0000000..e2b559f --- /dev/null +++ b/cloudflare.go @@ -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) +} diff --git a/docker-compose.yml b/docker-compose.yml index 5971183..8ec1164 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,10 +1,9 @@ services: cloudflare-dynamic-dns: image: mxmlndml/cloudflare-dynamic-dns:latest - restart: always environment: - - API_KEY=123 - - ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353 - - DOMAIN_NAMES=dyndns.example.com,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" diff --git a/env.go b/env.go new file mode 100644 index 0000000..0592ca4 --- /dev/null +++ b/env.go @@ -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 +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0974ae0 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/mxmlndml/cloudflare-dynamic-dns + +go 1.22.0 diff --git a/main.go b/main.go new file mode 100644 index 0000000..25f5b99 --- /dev/null +++ b/main.go @@ -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) + } +} diff --git a/package.json b/package.json deleted file mode 100644 index 5010ec3..0000000 --- a/package.json +++ /dev/null @@ -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" - } -} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml deleted file mode 100644 index 606c08f..0000000 --- a/pnpm-lock.yaml +++ /dev/null @@ -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 diff --git a/public_ip.go b/public_ip.go new file mode 100644 index 0000000..f1e70d7 --- /dev/null +++ b/public_ip.go @@ -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)) +} diff --git a/src/cloudflare.ts b/src/cloudflare.ts deleted file mode 100644 index 9d06c86..0000000 --- a/src/cloudflare.ts +++ /dev/null @@ -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 }; diff --git a/src/getPublicIp.ts b/src/getPublicIp.ts deleted file mode 100644 index e62cf06..0000000 --- a/src/getPublicIp.ts +++ /dev/null @@ -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 => { - 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 => { - const messages: string[] = []; - - const requests = HTTPS_URLS.map(async (url: string): Promise => { - 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; diff --git a/src/index.ts b/src/index.ts deleted file mode 100644 index 3c8b3a7..0000000 --- a/src/index.ts +++ /dev/null @@ -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, -); diff --git a/src/log.ts b/src/log.ts deleted file mode 100644 index 0ea38e2..0000000 --- a/src/log.ts +++ /dev/null @@ -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 }; diff --git a/tsconfig.json b/tsconfig.json deleted file mode 100644 index 389a60c..0000000 --- a/tsconfig.json +++ /dev/null @@ -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 ''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. */ - } -}