17 Commits

Author SHA1 Message Date
mkelcik
13e2fa6f7b Merge pull request #11 from mkelcik/upgrade
Upgrade packages + go
2023-12-13 23:05:13 +01:00
mkelcik
401884304e upgrade go 2023-12-13 23:03:08 +01:00
mkelcik
df74a0e159 upgrade 2023-12-09 16:29:26 +01:00
mkelcik
316a40b662 upgrade 2023-12-09 16:20:31 +01:00
mkelcik
17afc65f92 upgrade 2023-12-09 16:15:22 +01:00
mkelcik
51958d719e Readme update 2023-05-04 22:43:09 +02:00
mkelcik
10a5a12b86 Readme update 2023-05-04 22:34:49 +02:00
mkelcik
c7646cbf63 Refactor 2023-05-04 17:48:03 +02:00
mkelcik
6a028ead30 Merge pull request #9 from mkelcik/notifications
Initial Notifiers implementation
2023-05-04 17:43:19 +02:00
mkelcik
fa2e4426f4 Refactor 2023-05-04 17:39:59 +02:00
mkelcik
f911b9ff16 Refactor 2023-05-04 17:28:25 +02:00
mkelcik
ffd5253f59 Initial Notifiers implementation 2023-05-04 11:44:27 +02:00
mkelcik
2d52cbe920 Merge pull request #8 from mkelcik/new-1_1_1_1_resolver
Add 1.1.1.1 resolver
2023-05-03 23:17:20 +02:00
mkelcik
6f1b45cf8a Add 1.1.1.1 resolver 2023-05-03 23:06:57 +02:00
mkelcik
f859e86a08 Merge pull request #7 from mkelcik/badges
Badges
2023-05-01 12:45:12 +02:00
mkelcik
bbcc6eaa44 Update README.md 2023-05-01 12:44:40 +02:00
mkelcik
3222a6c54c Update README.md 2023-05-01 12:43:19 +02:00
18 changed files with 381 additions and 35 deletions

View File

@@ -22,7 +22,7 @@ jobs:
steps: steps:
- uses: actions/setup-go@v4 - uses: actions/setup-go@v4
with: with:
go-version: '1.20' go-version: '1.21'
cache: false cache: false
- uses: actions/checkout@v3 - uses: actions/checkout@v3
- name: golangci-lint - name: golangci-lint
@@ -39,7 +39,7 @@ jobs:
- name: Prepare go environment - name: Prepare go environment
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.20' go-version: '1.21.5'
cache: false cache: false
- name: Install dep scanner - name: Install dep scanner
run: | run: |
@@ -58,7 +58,7 @@ jobs:
- name: Prepare go environment - name: Prepare go environment
uses: actions/setup-go@v4 uses: actions/setup-go@v4
with: with:
go-version: '1.20' go-version: '1.21'
cache: false cache: false
- name: Run tests - name: Run tests
run: go test --cover -coverprofile coverage.out -covermode count -v ./... run: go test --cover -coverprofile coverage.out -covermode count -v ./...

View File

@@ -1,10 +1,10 @@
FROM golang:1.20 as build FROM golang:1.21 as build
# Copy project sources # Copy project sources
COPY . /opt/project/ COPY . /opt/project/
WORKDIR /opt/project WORKDIR /opt/project
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20210119 RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20230311
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /cloudflare-ddns-updater RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /cloudflare-ddns-updater

2
Makefile Normal file
View File

@@ -0,0 +1,2 @@
test:
go test --cover -covermode count -v ./...

View File

@@ -1,9 +1,11 @@
![Code and security checks](https://github.com/mkelcik/cloudflare-ddns-update/actions/workflows/quality-checks.yml/badge.svg)
## What is Cloudflare Dynamic DNS? ## What is Cloudflare Dynamic DNS?
DNS records are static, and it does not play well with dynamic IP addresses. Now, to solve that problem, youll need to set up dynamic DNS. Cloudflare provides an API that allows you to manage DNS records programmatically. DNS records are static, and it does not play well with dynamic IP addresses. Now, to solve that problem, youll need to set up dynamic DNS. Cloudflare provides an API that allows you to manage DNS records programmatically.
To set up a Cloudflare dynamic DNS, youll need to run a process on a client inside your network that does two main actions: get your networks current public IP address and automatically update the corresponding DNS record. To set up a Cloudflare dynamic DNS, youll need to run a process on a client inside your network that does two main actions: get your networks current public IP address and automatically update the corresponding DNS record.
This simple updater do the job. This simple updater do the job, and send notifications, if change happen.
## How to run ## How to run
### Environment variables ### Environment variables
@@ -15,7 +17,25 @@ Before run, you need configure this environment variables.
- `CLOUDFLARE_ZONE` - (required) zone name with domain you want to check. See: [https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones](https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones) - `CLOUDFLARE_ZONE` - (required) zone name with domain you want to check. See: [https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones](https://developers.cloudflare.com/fundamentals/get-started/concepts/accounts-and-zones/#zones)
- `ON_CHANGE_COMMENT` - (optional) in the event that the ip address of the dns record changes, this comment will be added to the record - `ON_CHANGE_COMMENT` - (optional) in the event that the ip address of the dns record changes, this comment will be added to the record
- `CHECK_INTERVAL_SECONDS` - (optional) how often will the ip address of the records be checked (default: `300`) - `CHECK_INTERVAL_SECONDS` - (optional) how often will the ip address of the records be checked (default: `300`)
- `PUBLIC_IP_RESOLVER` - (optional) public ip address resolver. (default: `ifconfig.me`) Available: `ifconfig.me`, `v4.ident.me` - `PUBLIC_IP_RESOLVER` - (optional) public ip address resolver. (default: `ifconfig.me`) Available: `ifconfig.me`, `v4.ident.me`, `1.1.1.1`
- `NOTIFIERS` - (optional) setting the notifier in case of an update of the dns record. Multiple entries are separated by commas. (default none). Example: `webhook@http://localhost/cloudflare-notification`
### Notifications
Currently, only webhook notification is available. Webhook sends a POST request to the specified endpoint in json format.
Request body example:
```json
{
"old_ip": "xxx.xxx.xxx.xxx",
"new_ip": "xxx.xxx.xxx.xxx",
"checked_at": "2023-05-04T17:39:42.942850354+02:00",
"resolver_tag": "ifconfig.me",
"domain": "my.domain.com"
}
```
Other notification methods will be implemented later (check future plans section).
### Building from source ### Building from source
@@ -47,6 +67,7 @@ services:
environment: environment:
- CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com - CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com
- CLOUDFLARE_API_KEY=your_cloudflare_api_key - CLOUDFLARE_API_KEY=your_cloudflare_api_key
- NOTIFIERS=webhook@http://localhost/cloudflare-updated-notification
- CLOUDFLARE_ZONE=testdomain.com - CLOUDFLARE_ZONE=testdomain.com
- ON_CHANGE_COMMENT="automatically updated" - ON_CHANGE_COMMENT="automatically updated"
- CHECK_INTERVAL_SECONDS=300 - CHECK_INTERVAL_SECONDS=300
@@ -57,6 +78,12 @@ services:
docker run -e CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com -e CLOUDFLARE_API_KEY=your_cloudflare_api_key -e CLOUDFLARE_ZONE=testdomain.com -e ON_CHANGE_COMMENT="automatically updated" -e CHECK_INTERVAL_SECONDS=300 mkelcik/cloudflare-ddns-update:latest docker run -e CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com -e CLOUDFLARE_API_KEY=your_cloudflare_api_key -e CLOUDFLARE_ZONE=testdomain.com -e ON_CHANGE_COMMENT="automatically updated" -e CHECK_INTERVAL_SECONDS=300 mkelcik/cloudflare-ddns-update:latest
``` ```
### Future plans
- prometheus metrics
- mqtt and rabbitmq notifiers
- IPv6 support
### Contributing ### Contributing
Feel free to contribute and pls report bugs. Thanks Feel free to contribute and pls report bugs. Thanks

13
go.mod
View File

@@ -1,14 +1,15 @@
module github.com/mkelcik/cloudflare-ddns-update module github.com/mkelcik/cloudflare-ddns-update
go 1.20 go 1.21
require github.com/cloudflare/cloudflare-go v0.66.0 require github.com/cloudflare/cloudflare-go v0.83.0
require ( require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2 // indirect github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
golang.org/x/net v0.9.0 // indirect golang.org/x/net v0.19.0 // indirect
golang.org/x/text v0.9.0 // indirect golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.3.0 // indirect golang.org/x/time v0.5.0 // indirect
) )

13
go.sum
View File

@@ -1,8 +1,12 @@
github.com/cloudflare/cloudflare-go v0.66.0 h1:B74IvVGQ4UFYJnqQSK/9GbR+Y1HwNxqqdN2Bmg0dckg= github.com/cloudflare/cloudflare-go v0.66.0 h1:B74IvVGQ4UFYJnqQSK/9GbR+Y1HwNxqqdN2Bmg0dckg=
github.com/cloudflare/cloudflare-go v0.66.0/go.mod h1:tA44hjU9FfycofKT+lWWMHb/dEq1pRbiVPGuJo1WzLQ= github.com/cloudflare/cloudflare-go v0.66.0/go.mod h1:tA44hjU9FfycofKT+lWWMHb/dEq1pRbiVPGuJo1WzLQ=
github.com/cloudflare/cloudflare-go v0.83.0 h1:aq85Hbr5W6KfXZV7v3lx6fhBkiu0FYqY+3+xzG14mdY=
github.com/cloudflare/cloudflare-go v0.83.0/go.mod h1:5pkAzpoWJYI5NekLZoRryQAcghYDhdbUxdcal1f7lu4=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg= github.com/google/go-cmp v0.5.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
@@ -13,6 +17,8 @@ github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrj
github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM= github.com/hashicorp/go-hclog v1.2.0 h1:La19f8d7WIlm4ogzNHB0JGqs5AUDAZ2UfCY4sJXcJdM=
github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0= github.com/hashicorp/go-retryablehttp v0.7.2 h1:AcYqCvkpalPnPF2pn0KamgwamS42TqUDDYFRKq/RAd0=
github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8= github.com/hashicorp/go-retryablehttp v0.7.2/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-retryablehttp v0.7.5 h1:bJj+Pj19UZMIweq/iie+1u5YCdGrnxCT9yvm0e+Nd5M=
github.com/hashicorp/go-retryablehttp v0.7.5/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40= github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y= github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -21,10 +27,17 @@ github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXf
github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8= github.com/stretchr/testify v1.8.2 h1:+h33VjcLVPDHtOdpUCuF+7gSuG3yGIftsP1YvFihtJ8=
golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM= golang.org/x/net v0.9.0 h1:aWJ/m6xSmxWBx+V0XRHTlrYrPG56jKsLdTFmsSsCzOM=
golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns= golang.org/x/net v0.9.0/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/net v0.19.0 h1:zTwKpTd2XuCqf8huc7Fo2iSy+4RHPd10s4KzeTnVr1c=
golang.org/x/net v0.19.0/go.mod h1:CfAk/cbD4CthTvqiEl8NpboMuiuOYsAr/7NOjZJtv1U=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU= golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
golang.org/x/sys v0.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE= golang.org/x/text v0.9.0 h1:2sjJmO8cDvYveuX97RDLsxlyUxLl+GHoLxBiRdHllBE=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ=
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4= golang.org/x/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ= golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.5.0 h1:o7cqy6amK/52YcAKIPlM3a+Fpj35zvRj2TP+e1xFSfk=
golang.org/x/time v0.5.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=

View File

@@ -17,6 +17,7 @@ const (
envKeyCloudflareZone = "CLOUDFLARE_ZONE" envKeyCloudflareZone = "CLOUDFLARE_ZONE"
envKeyOnChangeComment = "ON_CHANGE_COMMENT" envKeyOnChangeComment = "ON_CHANGE_COMMENT"
envKeyCheckIntervalSeconds = "CHECK_INTERVAL_SECONDS" envKeyCheckIntervalSeconds = "CHECK_INTERVAL_SECONDS"
envKeyNotifiers = "NOTIFIERS"
) )
type Config struct { type Config struct {
@@ -25,6 +26,7 @@ type Config struct {
ApiToken string ApiToken string
CloudflareZone string CloudflareZone string
OnChangeComment string OnChangeComment string
Notifiers []string
CheckInterval time.Duration CheckInterval time.Duration
} }
@@ -52,11 +54,12 @@ func NewConfig() Config {
} }
return Config{ return Config{
DnsRecordsToCheck: parseDNSToCheck(os.Getenv(envKeyDnsToCheck)), DnsRecordsToCheck: parseCommaDelimited(os.Getenv(envKeyDnsToCheck)),
PublicIpResolverTag: os.Getenv(envKeyPublicIpResolverTag), PublicIpResolverTag: os.Getenv(envKeyPublicIpResolverTag),
ApiToken: os.Getenv(envKeyCloudflareApiKey), ApiToken: os.Getenv(envKeyCloudflareApiKey),
CloudflareZone: os.Getenv(envKeyCloudflareZone), CloudflareZone: os.Getenv(envKeyCloudflareZone),
OnChangeComment: os.Getenv(envKeyOnChangeComment), OnChangeComment: os.Getenv(envKeyOnChangeComment),
Notifiers: parseCommaDelimited(os.Getenv(envKeyNotifiers)),
CheckInterval: time.Duration(checkInterval) * time.Second, CheckInterval: time.Duration(checkInterval) * time.Second,
} }
} }

View File

@@ -2,10 +2,10 @@ package internal
import "strings" import "strings"
func parseDNSToCheck(data string) []string { func parseCommaDelimited(data string) []string {
out := make([]string, 0, strings.Count(data, ",")+1) out := make([]string, 0, strings.Count(data, ",")+1)
for _, dns := range strings.Split(data, ",") { for _, item := range strings.Split(data, ",") {
if w := strings.TrimSpace(dns); w != "" { if w := strings.TrimSpace(item); w != "" {
out = append(out, w) out = append(out, w)
} }
} }

View File

@@ -38,8 +38,8 @@ func Test_parseDNSToCheck(t *testing.T) {
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
if got := parseDNSToCheck(tt.args.data); !reflect.DeepEqual(got, tt.want) { if got := parseCommaDelimited(tt.args.data); !reflect.DeepEqual(got, tt.want) {
t.Errorf("parseDNSToCheck() = %v, want %v", got, tt.want) t.Errorf("parseCommaDelimited() = %v, want %v", got, tt.want)
} }
}) })
} }

23
main.go
View File

@@ -10,6 +10,7 @@ import (
"github.com/cloudflare/cloudflare-go" "github.com/cloudflare/cloudflare-go"
"github.com/mkelcik/cloudflare-ddns-update/internal" "github.com/mkelcik/cloudflare-ddns-update/internal"
"github.com/mkelcik/cloudflare-ddns-update/notifications"
"github.com/mkelcik/cloudflare-ddns-update/public_resolvers" "github.com/mkelcik/cloudflare-ddns-update/public_resolvers"
) )
@@ -20,6 +21,8 @@ type PublicIpResolver interface {
func getResolver(resolverName string) (PublicIpResolver, string) { func getResolver(resolverName string) (PublicIpResolver, string) {
switch resolverName { switch resolverName {
// HERE add another resolver if needed // HERE add another resolver if needed
case public_resolvers.CloudflareTraceTag:
return public_resolvers.NewDefaultCloudflareTrace(), public_resolvers.CloudflareTraceTag
case public_resolvers.V4IdentMeTag: case public_resolvers.V4IdentMeTag:
return public_resolvers.NewV4IdentMeDefault(), public_resolvers.V4IdentMeTag return public_resolvers.NewV4IdentMeDefault(), public_resolvers.V4IdentMeTag
case public_resolvers.IfConfigMeTag: case public_resolvers.IfConfigMeTag:
@@ -49,6 +52,8 @@ func main() {
log.Fatal(err) log.Fatal(err)
} }
notifiers := notifications.GetNotifiers(config.Notifiers)
// public ip resolver // public ip resolver
publicIpResolver, resolverTag := getResolver(config.PublicIpResolverTag) publicIpResolver, resolverTag := getResolver(config.PublicIpResolverTag)
@@ -57,7 +62,7 @@ func main() {
if err != nil { if err != nil {
log.Fatal(err) log.Fatal(err)
} }
log.Printf("Current public ip `%s` (%s)", currentPublicIP, resolverTag) log.Printf("Current public ip `%s` (resolver: %s)", currentPublicIP, resolverTag)
dns, err := allDNSRecords(ctx, api, cloudflare.ZoneIdentifier(zoneID)) dns, err := allDNSRecords(ctx, api, cloudflare.ZoneIdentifier(zoneID))
if err != nil { if err != nil {
@@ -78,14 +83,24 @@ func main() {
} }
if config.OnChangeComment != "" { if config.OnChangeComment != "" {
update.Comment = config.OnChangeComment update.Comment = &config.OnChangeComment
} }
if _, err := api.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), update); err != nil { if _, err := api.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), update); err != nil {
log.Printf("error updating dns record: %s", err) log.Printf("error updating dns record: %s", err)
} else { continue
log.Printf("Updated to `%s`", currentPublicIP)
} }
if err := notifiers.NotifyWithLog(ctx, notifications.Notification{
OldIp: net.ParseIP(dnsRecord.Content),
NewIp: currentPublicIP,
CheckedAt: time.Now(),
ResolverTag: resolverTag,
Domain: dnsRecord.Name,
}); err != nil {
log.Printf("errors in notifications: %s", err)
}
log.Printf("Updated to `%s`", currentPublicIP)
} }
} }
} }

76
notifications/types.go Normal file
View File

@@ -0,0 +1,76 @@
package notifications
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"strings"
"time"
)
const (
configDelimiter = "@"
)
type Notifiers []Notifier
func (n Notifiers) NotifyWithLog(ctx context.Context, notification Notification) error {
var outErr error
for _, notifier := range n {
if err := notifier.Notify(ctx, notification); err != nil {
outErr = errors.Join(outErr, err)
continue
}
log.Printf("Notification sent via %s\n", notifier.Tag())
}
return outErr
}
type Notification struct {
OldIp net.IP `json:"old_ip,omitempty"`
NewIp net.IP `json:"new_ip"`
CheckedAt time.Time `json:"checked_at"`
ResolverTag string `json:"resolver_tag"`
Domain string `json:"domain"`
}
func (n Notification) ToSlice() []string {
return []string{n.OldIp.String(), n.NewIp.String(), n.CheckedAt.Format(time.RFC3339), n.ResolverTag, n.Domain}
}
var Available = map[string]func(string) (Notifier, error){
webhookTag: func(config string) (Notifier, error) {
parts := strings.Split(config, configDelimiter)
if len(parts) < 2 {
return nil, fmt.Errorf("wrong webhook config, missing url part")
}
return NewWebhookNotification(WebhookConfig{Url: parts[1]}, &http.Client{
Timeout: 10 * time.Second,
}), nil
},
}
type Notifier interface {
Tag() string
Notify(ctx context.Context, notification Notification) error
}
func GetNotifiers(tags []string) Notifiers {
out := Notifiers{}
for _, t := range tags {
if initFn, ok := Available[strings.Split(t, configDelimiter)[0]]; ok {
notifier, err := initFn(t)
if err != nil {
log.Println(err)
continue
}
out = append(out, notifier)
}
}
return out
}

66
notifications/webhook.go Normal file
View File

@@ -0,0 +1,66 @@
package notifications
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
)
const (
webhookTag = "webhook"
)
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
type WebhookConfig struct {
Url string
}
type WebhookNotification struct {
config WebhookConfig
client Doer
}
func (w WebhookNotification) Tag() string {
return webhookTag
}
func NewWebhookNotification(config WebhookConfig, client Doer) *WebhookNotification {
return &WebhookNotification{config: config, client: client}
}
func (w WebhookNotification) getRequestBody(notification Notification) (io.Reader, error) {
out := bytes.NewBuffer(nil)
if err := json.NewEncoder(out).Encode(notification); err != nil {
return nil, fmt.Errorf("error encoding json notification body: %w", err)
}
return out, nil
}
func (w WebhookNotification) Notify(ctx context.Context, notification Notification) error {
body, err := w.getRequestBody(notification)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error: %w", err)
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, w.config.Url, body)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error creating request: %w", err)
}
resp, err := w.client.Do(req)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error while sending notification: %w", err)
}
_ = resp.Body.Close()
if resp.StatusCode >= 300 {
return fmt.Errorf("WebhookNotification::NotifyWithLog unexpected non 2xx code %d returned", resp.StatusCode)
}
return nil
}

View File

@@ -2,19 +2,30 @@ package public_resolvers
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"io" "io"
"net" "net"
"net/http" "net/http"
) )
var NoIPInResponseError = errors.New("no ip found in response")
type Doer interface { type Doer interface {
Do(*http.Request) (*http.Response, error) Do(*http.Request) (*http.Response, error)
} }
type ipParserFunc func(reader io.Reader) (string, error)
func defaultIpParser(reader io.Reader) (string, error) {
out, err := io.ReadAll(reader)
return string(out), err
}
type baseResolver struct { type baseResolver struct {
client Doer client Doer
url string url string
ipParser ipParserFunc
} }
func (i baseResolver) ResolvePublicIp(ctx context.Context) (net.IP, error) { func (i baseResolver) ResolvePublicIp(ctx context.Context) (net.IP, error) {
@@ -35,10 +46,10 @@ func (i baseResolver) ResolvePublicIp(ctx context.Context) (net.IP, error) {
return net.IP{}, fmt.Errorf("unexpected response code %d", resp.StatusCode) return net.IP{}, fmt.Errorf("unexpected response code %d", resp.StatusCode)
} }
ipText, err := io.ReadAll(resp.Body) ipText, err := i.ipParser(resp.Body)
if err != nil { if err != nil {
return net.IP{}, fmt.Errorf("error reading body: %w", err) return net.IP{}, fmt.Errorf("error reading body: %w", err)
} }
return net.ParseIP(string(ipText)), nil return net.ParseIP(ipText), nil
} }

View File

@@ -21,7 +21,7 @@ func (f RoundTripFunc) RoundTrip(req *http.Request) (*http.Response, error) {
// NewTestClient returns *http.Client with Transport replaced to avoid making real calls // NewTestClient returns *http.Client with Transport replaced to avoid making real calls
func NewTestClient(fn RoundTripFunc) *http.Client { func NewTestClient(fn RoundTripFunc) *http.Client {
return &http.Client{ return &http.Client{
Transport: RoundTripFunc(fn), Transport: fn,
} }
} }
@@ -54,6 +54,7 @@ func Test_baseResolver_ResolvePublicIp(t *testing.T) {
type fields struct { type fields struct {
client Doer client Doer
url string url string
fn ipParserFunc
} }
type args struct { type args struct {
ctx context.Context ctx context.Context
@@ -70,6 +71,7 @@ func Test_baseResolver_ResolvePublicIp(t *testing.T) {
fields: fields{ fields: fields{
client: client, client: client,
url: testUrl, url: testUrl,
fn: defaultIpParser,
}, },
args: args{ args: args{
ctx: context.Background(), ctx: context.Background(),
@@ -83,6 +85,7 @@ func Test_baseResolver_ResolvePublicIp(t *testing.T) {
i := baseResolver{ i := baseResolver{
client: tt.fields.client, client: tt.fields.client,
url: tt.fields.url, url: tt.fields.url,
ipParser: tt.fields.fn,
} }
got, err := i.ResolvePublicIp(tt.args.ctx) got, err := i.ResolvePublicIp(tt.args.ctx)
if (err != nil) != tt.wantErr { if (err != nil) != tt.wantErr {

View File

@@ -0,0 +1,49 @@
package public_resolvers
import (
"io"
"net/http"
"strings"
"time"
)
const (
CloudflareTraceTag = "1.1.1.1"
CloudflareTraceUrl = "https://1.1.1.1/cdn-cgi/trace"
ipPrefix = "ip="
)
type CloudflareTrace struct {
baseResolver
}
func NewDefaultCloudflareTrace() *CloudflareTrace {
return NewCloudflareTrace(&http.Client{
Timeout: 10 * time.Second,
})
}
func cloudflareTraceResponseParser(reader io.Reader) (string, error) {
data, err := io.ReadAll(reader)
if err != nil {
return "", err
}
for _, row := range strings.Split(string(data), "\n") {
if strings.Index(row, ipPrefix) == 0 {
return strings.TrimSpace(strings.ReplaceAll(row, ipPrefix, "")), nil
}
}
return "", NoIPInResponseError
}
func NewCloudflareTrace(client Doer) *CloudflareTrace {
return &CloudflareTrace{
baseResolver: baseResolver{
client: client,
url: CloudflareTraceUrl,
ipParser: cloudflareTraceResponseParser,
},
}
}

View File

@@ -0,0 +1,78 @@
package public_resolvers
import (
"bytes"
"io"
"testing"
)
func Test_cloudflareTraceResponseParser(t *testing.T) {
type args struct {
reader io.Reader
}
tests := []struct {
name string
args args
want string
wantErr bool
}{
{
name: "ok",
args: args{
reader: bytes.NewBuffer([]byte(`fl=31f118
h=1.1.1.1
ip=94.113.142.206
ts=1683145336.383
visit_scheme=https
uag=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36
colo=PRG
sliver=none
http=http/2
loc=CZ
tls=TLSv1.3
sni=off
warp=off
gateway=off
rbi=off
kex=X25519`)),
},
want: "94.113.142.206",
wantErr: false,
},
{
name: "no ip in response",
args: args{
reader: bytes.NewBuffer([]byte(`fl=31f118
h=1.1.1.1
ts=1683145336.383
visit_scheme=https
uag=Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36
colo=PRG
sliver=none
http=http/2
loc=CZ
tls=TLSv1.3
sni=off
warp=off
gateway=off
rbi=off
kex=X25519`)),
},
want: "",
wantErr: true,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got, err := cloudflareTraceResponseParser(tt.args.reader)
if (err != nil) != tt.wantErr {
t.Errorf("cloudflareTraceResponseParser() error = %v, wantErr %v", err, tt.wantErr)
return
}
if got != tt.want {
t.Errorf("cloudflareTraceResponseParser() got = %v, want %v", got, tt.want)
}
})
}
}

View File

@@ -25,6 +25,7 @@ func NewIfConfigMe(client Doer) *IfConfigMe {
baseResolver: baseResolver{ baseResolver: baseResolver{
client: client, client: client,
url: ifConfigMeUrl, url: ifConfigMeUrl,
ipParser: defaultIpParser,
}, },
} }
} }

View File

@@ -25,6 +25,7 @@ func NewV4IdentMe(client Doer) *V4IdentMe {
baseResolver: baseResolver{ baseResolver: baseResolver{
client: client, client: client,
url: v4IdentMeUrl, url: v4IdentMeUrl,
ipParser: defaultIpParser,
}, },
} }
} }