Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7c8cc275ed | ||
|
|
4744ef5922 | ||
|
|
cad4064f3e | ||
|
|
352e9e1874 | ||
|
|
7df6f5ebe2 |
@@ -1,5 +1,3 @@
|
|||||||
node_modules
|
|
||||||
.git
|
.git
|
||||||
.gitignore
|
.gitignore
|
||||||
*.md
|
*.md
|
||||||
dist
|
|
||||||
2
.env.template
Normal file
2
.env.template
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
API_KEY=123
|
||||||
|
ZONE_ID=023e105f4ecef8ad9ca31a8372d0c353
|
||||||
137
.gitignore
vendored
137
.gitignore
vendored
@@ -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.*
|
|
||||||
|
|||||||
24
Dockerfile
24
Dockerfile
@@ -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"]
|
||||||
|
|||||||
2
LICENSE
2
LICENSE
@@ -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
|
||||||
|
|||||||
64
README.md
64
README.md
@@ -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
|
This Docker container offers a straightforward and efficient solution for
|
||||||
always accessible through a domain name.
|
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
141
cloudflare.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
84
env.go
Normal 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
3
go.mod
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
module github.com/mxmlndml/cloudflare-dynamic-dns
|
||||||
|
|
||||||
|
go 1.22.0
|
||||||
132
main.go
Normal file
132
main.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
19
package.json
19
package.json
@@ -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
258
pnpm-lock.yaml
generated
@@ -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
24
public_ip.go
Normal 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))
|
||||||
|
}
|
||||||
@@ -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 };
|
|
||||||
@@ -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;
|
|
||||||
65
src/index.ts
65
src/index.ts
@@ -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,
|
|
||||||
);
|
|
||||||
50
src/log.ts
50
src/log.ts
@@ -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 };
|
|
||||||
109
tsconfig.json
109
tsconfig.json
@@ -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. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user