20 Commits

Author SHA1 Message Date
f31f2d74b7 SECURITY: Run as non-privileged user
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-18 10:13:45 +13:00
e1bb5adf36 Refactor: Moved all under internal.
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
FEAT: Directadmin provider is now working
2024-03-18 09:55:01 +13:00
a52034216b SKIP CI: Remved erroneus file
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-15 23:19:12 +13:00
17014eeae1 FEAT: Refactor allowing multiple DNS Providers
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-03-15 23:16:23 +13:00
a977adf929 Added icanhazip resolver
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-21 22:38:19 +13:00
5e4a7f8135 Update .woodpecker.yml
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2024-02-12 23:22:20 +13:00
f09bbfa6b7 Update .woodpecker.yml 2024-02-12 23:20:16 +13:00
cbe676846a Add Woodpecker pipeline
All checks were successful
ci/woodpecker/manual/woodpecker Pipeline was successful
2024-02-12 22:54:25 +13:00
cffeeaa2f8 Update README.md 2024-02-12 22:26:27 +13:00
adf83e7782 Specify that we are sending JSON 2024-02-12 17:10:02 +13:00
ddd6326a6e Added ability to set/add Webhook Token in payload 2024-02-12 16:50:56 +13:00
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
23 changed files with 565 additions and 140 deletions

View File

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

49
.woodpecker.yml Normal file
View File

@@ -0,0 +1,49 @@
variables:
- &platforms 'linux/arm/v7,linux/arm64/v8,linux/amd64'
- &docker_creds
username:
from_secret: hub_username_cybercinch
password:
from_secret: docker_password_cybercinch
- &pypi_creds
username:
from_secret: hub_username_cybercinch
password:
from_secret: docker_password_cybercinch
steps:
publish-docker-latest:
image: docker.io/cybercinch/woodpecker-plugin-depot
pull: true
settings:
<<: *docker_creds
token:
from_secret: depot_token
repohost: hub.cybercinch.nz
repo: cybercinch/${CI_REPO_NAME}
project: 8b4ht8th6p
dockerfile: Dockerfile
push: true
platforms: *platforms
tags: ["latest"]
when:
branch: ${CI_REPO_DEFAULT_BRANCH}
event:
- push
- manual
update-swarm-service-portainer:
image: docker.io/plugins/webhook
settings:
urls:
from_secret: deploy_url
method: POST
# depends_on: publish-docker-latest
# settings:
# <<: *docker_creds
# repohost: hub.cybercinch.nz
# repo: cybercinch/imap_retention_manager
# dockerfile: Dockerfile
# platforms: *platforms
# tags: ["latest", "${CI_COMMIT_TAG}"]
# when:
# event: tag

View File

@@ -1,15 +1,22 @@
FROM golang:1.20 as build
FROM golang:1.22 as build
# Copy project sources
COPY . /opt/project/
WORKDIR /opt/project
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20210119
# Install Pre-Requisites
RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates=20230311
# Create a user
RUN useradd --no-create-home --system --shell /bin/false ddnsuser
RUN CGO_ENABLED=0 go build -ldflags="-s -w" -o /cloudflare-ddns-updater
FROM scratch
COPY --from=build /etc/ssl/certs/ca-certificates.crt /etc/ssl/certs/
COPY --from=build /cloudflare-ddns-updater /cloudflare-ddns-updater
COPY --from=build /bin/false /bin/false
COPY --from=build /etc/passwd /etc/passwd
USER nobody
ENTRYPOINT ["/cloudflare-ddns-updater"]
CMD ["cloudflare-ddns-updater"]

View File

@@ -5,22 +5,38 @@ DNS records are static, and it does not play well with dynamic IP addresses. Now
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
### Environment variables
Before run, you need configure this environment variables.
- `CLOUDFLARE_DNS_TO_CHECK` - (required) dns records that will be automatically checked and modified based on the current public IP address. Multiple entries are separated by commas. For example: `domain.com,sub1.domain.com,sub2.domain.com`
- `DNS_NAMES` - (required) dns records that will be automatically checked and modified based on the current public IP address. Multiple entries are separated by commas. For example: `domain.com,sub1.domain.com,sub2.domain.com`
- `CLOUDFLARE_API_KEY` - (required) your cloudflare api key, with access rights to edit selected domains. See: [https://developers.cloudflare.com/fundamentals/api/get-started/create-token/](https://developers.cloudflare.com/fundamentals/api/get-started/create-token/)
- `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
- `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`, `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`
- Available(
- `webhook` - Call defined webhook. 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",
"token": "a-webhook-token"
}
```
Other notification methods will be implemented later (check future plans section).
### Building from source
@@ -42,19 +58,40 @@ go install github.com/mkelcik/cloudflare-ddns-update
CLOUDFLARE_DNS_TO_CHECK="domain.com" CLOUDFLARE_API_KEY="my_key" CLOUDFLARE_ZONE="domain.com" cloudflare-ddns-update
```
### Via `docker-compose`
### Via `docker-compose for Cloudflare`
```yaml
version: "3"
services:
cf-dns-updater:
image: mkelcik/cloudflare-ddns-update:latest
image: hub.cybercinch.nz/cybercinch/ddns-update:latest
restart: unless-stopped
environment:
- CLOUDFLARE_DNS_TO_CHECK=my.testdomain.com,your.testdomain.com
- CLOUDFLARE_API_KEY=your_cloudflare_api_key
- CLOUDFLARE_ZONE=testdomain.com
- DNS_NAMES=my.testdomain.com,your.testdomain.com
- NOTIFIERS=webhook@http://localhost/cloudflare-updated-notification
- ON_CHANGE_COMMENT="automatically updated"
- CHECK_INTERVAL_SECONDS=300
# Optional if your webhook receiver requires a token for verification
# - WEBHOOK_TOKEN="SomeSup3rs3cureT0k3n"
```
### Via `docker-compose for DirectAdmin`
```yaml
version: "3"
services:
cf-dns-updater:
image: hub.cybercinch.nz/cybercinch/ddns-update:latest
restart: unless-stopped
environment:
- DNS_PROVIDER=directadmin
- DA_USER=some-directadmin-username
- DA_KEY=your-directadmin-password-or-login-key
- DA_URL=https://your.daserver.com:2222
- DNS_NAMES=my.testdomain.com,your.testdomain.com
- NOTIFIERS=webhook@http://localhost/cloudflare-updated-notification
- CHECK_INTERVAL_SECONDS=300
# Optional if your webhook receiver requires a token for verification
# - WEBHOOK_TOKEN="SomeSup3rs3cureT0k3n"
```
### Via `docker run`
@@ -62,6 +99,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
```
### Future plans
- prometheus metrics
- mqtt and rabbitmq notifiers
- IPv6 support
### Contributing
Feel free to contribute and pls report bugs. Thanks

22
go.mod
View File

@@ -1,14 +1,22 @@
module github.com/mkelcik/cloudflare-ddns-update
module hub.cybercinch.nz/cybercinch/ddns-update
go 1.20
require github.com/cloudflare/cloudflare-go v0.66.0
go 1.22
toolchain go1.22.1
require (
github.com/cloudflare/cloudflare-go v0.83.0
github.com/levelzerotechnology/directadmin-go v0.0.0-20240302013738-63b7793ebfd3
)
require (
github.com/goccy/go-json v0.10.2 // indirect
github.com/google/go-querystring v1.1.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.2 // indirect
golang.org/x/net v0.9.0 // indirect
golang.org/x/text v0.9.0 // indirect
golang.org/x/time v0.3.0 // indirect
github.com/hashicorp/go-retryablehttp v0.7.5 // indirect
github.com/spf13/cast v1.6.0 // indirect
golang.org/x/net v0.19.0 // indirect
golang.org/x/text v0.14.0 // indirect
golang.org/x/time v0.5.0 // indirect
)

48
go.sum
View File

@@ -1,30 +1,52 @@
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.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/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
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.8 h1:e6P7q2lk1O+qJJb4BtCQXlK8vWEO8V1ZeuEdJNOqZyg=
github.com/google/go-cmp v0.5.9 h1:O2Tfq5qg4qc4AmwVlvv0oLiVAGB7enBSJ2x2DqQFi38=
github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8=
github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v0.9.2/go.mod h1:5CU+agLiy3J7N7QjHK5d05KxGsuXiQLrjA0H7acj2lQ=
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/go.mod h1:Jy/gPYAdjqffZ/yFGCFV2doI5wjtH1ewM9u8iYVjtX8=
github.com/hashicorp/go-hclog v1.2.0/go.mod h1:whpDNt7SSdeAju8AWKIWsul05p54N/39EeqMAyrmvFQ=
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/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/levelzerotechnology/directadmin-go v0.0.0-20240302013738-63b7793ebfd3 h1:6kGC8EpZlYTvNI2ABYX87bBtgzfPrtZV8CKqLtSvyek=
github.com/levelzerotechnology/directadmin-go v0.0.0-20240302013738-63b7793ebfd3/go.mod h1:FJ/EtEMwe3k2ABVYLXmE/K+H8cHVpVaSgihVT2XbSkc=
github.com/mattn/go-colorable v0.1.12 h1:jF+Du6AlPIjs2BiUiQlKOX0rt3SujHxPnksPKZbaA40=
github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4=
github.com/mattn/go-isatty v0.0.14 h1:yVuAays6BHfxijgZPzw+3Zlu5yQgKGP2/hcQbHb7S9Y=
github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
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/go.mod h1:d48xBJpPfHeWQsugry2m+kC02ZBRGRgulfHnEXEuWns=
golang.org/x/sys v0.7.0 h1:3jlCCIQZPdOYu1h8BkNvLz8Kgwtae2cagcG/VamtZRU=
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/time v0.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
golang.org/x/time v0.3.0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
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.15.0 h1:h48lPFYpsTvQJZF4EKyI4aLHaev3CxivZmv7yZig9pc=
golang.org/x/sys v0.15.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
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.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=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -9,34 +9,56 @@ import (
)
const (
defaultCheckInterval = 5 * 60
envKeyDnsToCheck = "CLOUDFLARE_DNS_TO_CHECK"
defaultCheckInterval = 5 * 60
envKeyDnsToCheck = "DNS_NAMES"
envKeyPublicIpResolverTag = "PUBLIC_IP_RESOLVER"
envKeyDNSProviderTag = "DNS_PROVIDER"
envKeyPublicDNSServer = "PUBLIC_DNS_SERVER"
envKeyCloudflareApiKey = "CLOUDFLARE_API_KEY"
envKeyCloudflareZone = "CLOUDFLARE_ZONE"
envKeyDirectadminUser = "DA_USER"
envKeyDirectadminKey = "DA_KEY"
envKeyDirectadminUrl = "DA_URL"
envKeyOnChangeComment = "ON_CHANGE_COMMENT"
envKeyCheckIntervalSeconds = "CHECK_INTERVAL_SECONDS"
envKeyNotifiers = "NOTIFIERS"
)
type Config struct {
DnsRecordsToCheck []string
PublicIpResolverTag string
ApiToken string
CloudflareZone string
OnChangeComment string
Notifiers []string
CheckInterval time.Duration
DnsRecordsToCheck []string
PublicIpResolverTag string
PublicDNSServer string
DNSProviderTag string
DirectadminUsername string
DirectadminKey string
DirectadminUrl string
ApiToken string
WebhookToken string
CloudflareZone string
CloudflareOnChangeComment string
Notifiers []string
CheckInterval time.Duration
}
func (c Config) Validate() error {
if c.ApiToken == "" {
return fmt.Errorf("empty api token env key %s", envKeyCloudflareApiKey)
}
switch c.DNSProviderTag {
case "cloudflare":
if c.ApiToken == "" {
return fmt.Errorf("empty api token env key %s", envKeyCloudflareApiKey)
}
if c.CloudflareZone == "" {
return fmt.Errorf("empty zone in env key %s", envKeyCloudflareZone)
case "directadmin":
if c.DirectadminUrl == "" {
return fmt.Errorf("empty DirectAdmin URL env key %s", envKeyDirectadminUrl)
}
if c.DirectadminUsername == "" {
return fmt.Errorf("empty DirectAdmin Username in env key %s", envKeyDirectadminUser)
}
if c.DirectadminKey == "" {
return fmt.Errorf("empty DirectAdmin Login Key in env key %s", envKeyDirectadminKey)
}
}
if len(c.DnsRecordsToCheck) == 0 {
@@ -54,12 +76,26 @@ func NewConfig() Config {
}
return Config{
DnsRecordsToCheck: parseCommaDelimited(os.Getenv(envKeyDnsToCheck)),
PublicIpResolverTag: os.Getenv(envKeyPublicIpResolverTag),
ApiToken: os.Getenv(envKeyCloudflareApiKey),
CloudflareZone: os.Getenv(envKeyCloudflareZone),
OnChangeComment: os.Getenv(envKeyOnChangeComment),
Notifiers: parseCommaDelimited(os.Getenv(envKeyNotifiers)),
CheckInterval: time.Duration(checkInterval) * time.Second,
DnsRecordsToCheck: parseCommaDelimited(os.Getenv(envKeyDnsToCheck)),
DNSProviderTag: getEnvDefault(envKeyDNSProviderTag, "cloudflare"),
PublicDNSServer: getEnvDefault(envKeyPublicDNSServer, "1.1.1.1"),
PublicIpResolverTag: getEnvDefault(envKeyPublicIpResolverTag, "icanhazip"),
ApiToken: os.Getenv(envKeyCloudflareApiKey),
CloudflareZone: os.Getenv(envKeyCloudflareZone),
CloudflareOnChangeComment: os.Getenv(envKeyOnChangeComment),
DirectadminUsername: os.Getenv(envKeyDirectadminUser),
DirectadminKey: os.Getenv(envKeyDirectadminKey),
DirectadminUrl: os.Getenv(envKeyDirectadminUrl),
Notifiers: parseCommaDelimited(os.Getenv(envKeyNotifiers)),
CheckInterval: time.Duration(checkInterval) * time.Second,
WebhookToken: os.Getenv("WEBHOOK_TOKEN"),
}
}
func getEnvDefault(key, fallback string) string {
value, exists := os.LookupEnv(key)
if !exists {
value = fallback
}
return value
}

View File

@@ -59,12 +59,12 @@ func TestConfig_Validate(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
c := Config{
DnsRecordsToCheck: tt.fields.DnsRecordsToCheck,
PublicIpResolverTag: tt.fields.PublicIpResolverTag,
ApiToken: tt.fields.ApiToken,
CloudflareZone: tt.fields.CloudflareZone,
OnChangeComment: tt.fields.OnChangeComment,
CheckInterval: tt.fields.CheckInterval,
DnsRecordsToCheck: tt.fields.DnsRecordsToCheck,
PublicIpResolverTag: tt.fields.PublicIpResolverTag,
ApiToken: tt.fields.ApiToken,
CloudflareZone: tt.fields.CloudflareZone,
CloudflareOnChangeComment: tt.fields.OnChangeComment,
CheckInterval: tt.fields.CheckInterval,
}
if err := c.Validate(); (err != nil) != tt.wantErr {
t.Errorf("Validate() error = %v, wantErr %v", err, tt.wantErr)

View File

@@ -0,0 +1,33 @@
package dns_providers
import (
"strings"
)
type DNSProvider interface {
UpdateRecord(hostname string, ip string) error
FetchRecord(hostname string) (string, error)
}
type DomainParts struct {
Name string
Domain string
}
func GetDomainParts(hostname string) *DomainParts {
data := arrayToSlice(strings.Split(hostname, "."))
if len(data) <= 2 {
// This might be the actual root domain
out := DomainParts{Name: strings.Join(data[0:], "."), Domain: strings.Join(data[0:], ".")}
return &out
} else {
// This is a subdomain
out := DomainParts{Name: data[0], Domain: strings.Join(data[1:], ".")}
return &out
}
}
func arrayToSlice(array []string) []string {
return array[:]
}

View File

@@ -0,0 +1,92 @@
package dns_providers
import (
"context"
"log"
"github.com/cloudflare/cloudflare-go"
"hub.cybercinch.nz/cybercinch/ddns-update/internal"
)
const (
CloudflareTag = "cloudflare"
)
type CloudflareProvider struct {
Context context.Context
Client *cloudflare.API
Config internal.Config
}
func (d *CloudflareProvider) UpdateRecord(hostname string, ip string) error {
// old_ip is not required for Cloudflare updates
domain_parts := GetDomainParts(hostname)
zoneId := d.FindZoneIdByName(domain_parts.Domain)
dnsRecords, err := d.GetDnsRecord(hostname, zoneId)
if err != nil {
log.Fatal(err)
}
update := cloudflare.UpdateDNSRecordParams{
ID: dnsRecords[0].ID,
Content: ip,
}
if d.Config.CloudflareOnChangeComment != "" {
update.Comment = &d.Config.CloudflareOnChangeComment
}
_, err = d.Client.UpdateDNSRecord(d.Context, cloudflare.ZoneIdentifier(zoneId), update)
if err != nil {
return err
}
return nil
}
func (d *CloudflareProvider) FetchRecord(hostname string) (string, error) {
domain_parts := GetDomainParts(hostname)
zoneId := d.FindZoneIdByName(domain_parts.Domain)
dnsRecord, err := d.GetDnsRecord(hostname, zoneId)
return dnsRecord[0].Content, err
}
func (d *CloudflareProvider) NewClient(api_token string) {
api, err := cloudflare.NewWithAPIToken(api_token)
if err != nil {
log.Fatal(err)
}
d.Client = api
}
func (d *CloudflareProvider) FindZoneIdByName(hostname string) string {
// Fetch user details on the account
zoneID, err := d.Client.ZoneIDByName(hostname)
if err != nil {
log.Fatal(err)
}
return zoneID
}
func (d *CloudflareProvider) GetDnsRecord(name string, zoneId string) ([]cloudflare.DNSRecord, error) {
params := cloudflare.ListDNSRecordsParams{
Name: name,
}
page, _, err := d.Client.ListDNSRecords(d.Context, cloudflare.ZoneIdentifier(zoneId), params)
if err != nil {
return nil, err
}
return page, nil
}
func NewCloudflareProvider(ctx context.Context, config internal.Config) *CloudflareProvider {
c := &CloudflareProvider{}
c.Context = ctx
c.Config = config
c.NewClient(c.Config.ApiToken)
return c
}

View File

@@ -0,0 +1,91 @@
package dns_providers
import (
"context"
"fmt"
"log"
"time"
"github.com/levelzerotechnology/directadmin-go"
"hub.cybercinch.nz/cybercinch/ddns-update/internal"
)
const (
DirectadminTag = "directadmin"
)
type Directadmin struct {
Client *directadmin.UserContext
Context context.Context
Config internal.Config
}
type ListDNSRecordsParams struct {
Name string
}
func (d *Directadmin) FetchRecord(hostname string) (string, error) {
dnsRecord, err := d.GetDnsRecord(hostname)
if err != nil {
log.Fatal("unable to retrieve DNS record")
return "", err
}
return dnsRecord[0].Value, nil
}
func (d *Directadmin) UpdateRecord(hostname string, ip string) error {
result := GetDomainParts(hostname)
current_record, _ := d.GetDnsRecord(hostname)
// Create an updated record for the new ip
new_record := directadmin.DNSRecord{Name: current_record[0].Name, Ttl: current_record[0].Ttl, Type: current_record[0].Type, Value: ip}
err := d.Client.UpdateDNSRecord(result.Domain, current_record[0], new_record)
if err != nil {
return err
}
return nil
}
func (d *Directadmin) GetDnsRecord(hostname string) ([]directadmin.DNSRecord, error) {
domainParts := GetDomainParts(hostname)
dnsRecords, err := d.Client.GetDNSRecords(domainParts.Domain)
var slice []directadmin.DNSRecord
for _, dnsRecord := range dnsRecords {
if domainParts.Name == dnsRecord.Name {
slice = append(slice, dnsRecord)
}
}
if len(slice) == 0 {
return nil, fmt.Errorf("unable to find DNS record for %s", hostname)
}
return slice, err
}
func (d *Directadmin) NewClient(server_url string, username string, key string) {
api, err := directadmin.New(server_url, 5*time.Second, false, false)
if err != nil {
panic(err)
}
userCtx, err := api.LoginAsUser(username, key)
if err != nil {
panic(err)
}
d.Client = userCtx
}
func NewDirectAdminProvider(ctx context.Context, config internal.Config) *Directadmin {
c := &Directadmin{}
c.Context = ctx
c.Config = config
c.NewClient(config.DirectadminUrl, config.DirectadminUsername, config.DirectadminKey)
return c
}

View File

@@ -0,0 +1,29 @@
package dns_resolver
import (
"context"
"fmt"
"net"
"os"
"time"
)
func ResolveHostname(host string, dns_resolver_ip string) net.IP {
r := &net.Resolver{
PreferGo: true,
Dial: func(ctx context.Context, network, address string) (net.Conn, error) {
d := net.Dialer{
Timeout: time.Millisecond * time.Duration(10000),
}
return d.DialContext(ctx, network, dns_resolver_ip+":53")
},
}
ips, err := r.LookupHost(context.Background(), host)
if err != nil {
fmt.Fprintf(os.Stderr, "Could not get IPs: %v\n", err)
os.Exit(1)
}
return net.ParseIP(ips[len(ips)-1])
}

View File

@@ -1,6 +1,8 @@
package internal
import "strings"
import (
"strings"
)
func parseCommaDelimited(data string) []string {
out := make([]string, 0, strings.Count(data, ",")+1)

View File

@@ -15,6 +15,10 @@ const (
configDelimiter = "@"
)
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
type Notifiers []Notifier
func (n Notifiers) NotifyWithLog(ctx context.Context, notification Notification) error {
@@ -30,11 +34,12 @@ func (n Notifiers) NotifyWithLog(ctx context.Context, notification Notification)
}
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"`
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"`
WebhookToken string `json:"token,omitempty"`
}
func (n Notification) ToSlice() []string {

View File

@@ -13,10 +13,6 @@ const (
webhookTag = "webhook"
)
type Doer interface {
Do(*http.Request) (*http.Response, error)
}
type WebhookConfig struct {
Url string
}
@@ -53,6 +49,8 @@ func (w WebhookNotification) Notify(ctx context.Context, notification Notificati
return fmt.Errorf("WebhookNotification::NotifyWithLog error creating request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := w.client.Do(req)
if err != nil {
return fmt.Errorf("WebhookNotification::NotifyWithLog error while sending notification: %w", err)

View File

@@ -0,0 +1,31 @@
package public_resolvers
import (
"net/http"
"time"
)
const (
IcanhazipTag = "icanhazip"
IcanhazipUrl = "https://v4.icanhazip.com/"
)
type Icanhazip struct {
baseResolver
}
func NewIcanhazipDefault() *Icanhazip {
return NewIcanhazip(&http.Client{
Timeout: 10 * time.Second,
})
}
func NewIcanhazip(client Doer) *Icanhazip {
return &Icanhazip{
baseResolver: baseResolver{
client: client,
url: v4IdentMeUrl,
ipParser: defaultIpParser,
},
}
}

115
main.go
View File

@@ -8,10 +8,10 @@ import (
"syscall"
"time"
"github.com/cloudflare/cloudflare-go"
"github.com/mkelcik/cloudflare-ddns-update/internal"
"github.com/mkelcik/cloudflare-ddns-update/notifications"
"github.com/mkelcik/cloudflare-ddns-update/public_resolvers"
"hub.cybercinch.nz/cybercinch/ddns-update/internal"
"hub.cybercinch.nz/cybercinch/ddns-update/internal/dns_providers"
"hub.cybercinch.nz/cybercinch/ddns-update/internal/notifications"
"hub.cybercinch.nz/cybercinch/ddns-update/internal/public_resolvers"
)
type PublicIpResolver interface {
@@ -25,6 +25,8 @@ func getResolver(resolverName string) (PublicIpResolver, string) {
return public_resolvers.NewDefaultCloudflareTrace(), public_resolvers.CloudflareTraceTag
case public_resolvers.V4IdentMeTag:
return public_resolvers.NewV4IdentMeDefault(), public_resolvers.V4IdentMeTag
case public_resolvers.IcanhazipTag:
return public_resolvers.NewIcanhazipDefault(), public_resolvers.IcanhazipTag
case public_resolvers.IfConfigMeTag:
fallthrough
default:
@@ -32,6 +34,17 @@ func getResolver(resolverName string) (PublicIpResolver, string) {
}
}
func getProvider(ctx context.Context, config internal.Config) (dns_providers.DNSProvider, string) {
switch config.DNSProviderTag {
case dns_providers.DirectadminTag:
return dns_providers.NewDirectAdminProvider(ctx, config), dns_providers.DirectadminTag
case dns_providers.CloudflareTag:
fallthrough
default:
return dns_providers.NewCloudflareProvider(ctx, config), dns_providers.CloudflareTag
}
}
func main() {
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
@@ -41,67 +54,53 @@ func main() {
log.Fatalln(err)
}
api, err := cloudflare.NewWithAPIToken(config.ApiToken)
if err != nil {
log.Fatal(err)
}
// Fetch user details on the account
zoneID, err := api.ZoneIDByName(config.CloudflareZone)
if err != nil {
log.Fatal(err)
}
notifiers := notifications.GetNotifiers(config.Notifiers)
// public ip resolver
publicIpResolver, resolverTag := getResolver(config.PublicIpResolverTag)
dnsProvider, providerTag := getProvider(ctx, config)
log.Printf("Using DNS Provider %s", providerTag)
checkFunc := func() {
currentPublicIP, err := publicIpResolver.ResolvePublicIp(ctx)
if err != nil {
log.Fatal(err)
}
log.Printf("Current public ip `%s` (resolver: %s)", currentPublicIP, resolverTag)
dns, err := allDNSRecords(ctx, api, cloudflare.ZoneIdentifier(zoneID))
if err != nil {
log.Fatal(err)
}
for _, dnsRecord := range dns {
if internal.Contains(config.DnsRecordsToCheck, dnsRecord.Name) {
log.Printf("Checking record `%s` with current value `%s` ...", dnsRecord.Name, dnsRecord.Content)
if currentPublicIP.String() == dnsRecord.Content {
log.Println("OK")
continue // no update needed
}
update := cloudflare.UpdateDNSRecordParams{
ID: dnsRecord.ID,
Content: currentPublicIP.String(),
}
if config.OnChangeComment != "" {
update.Comment = config.OnChangeComment
}
if _, err := api.UpdateDNSRecord(ctx, cloudflare.ZoneIdentifier(zoneID), update); err != nil {
log.Printf("error updating dns record: %s", err)
continue
}
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)
for _, dnsRecord := range config.DnsRecordsToCheck {
current_dns_record, err := dnsProvider.FetchRecord(dnsRecord)
if err != nil {
log.Fatalf("Failed to fetch DNS record: %s", err)
}
log.Printf("Checking record `%s` with current value `%s` ...", dnsRecord, current_dns_record)
if currentPublicIP.String() == current_dns_record {
log.Println("OK")
continue // no update needed
}
if err := dnsProvider.UpdateRecord(dnsRecord, currentPublicIP.String()); err != nil {
log.Printf("error updating dns record: %s", err)
continue
}
if err := notifiers.NotifyWithLog(ctx, notifications.Notification{
OldIp: net.ParseIP(string(current_dns_record)),
NewIp: currentPublicIP,
CheckedAt: time.Now(),
ResolverTag: resolverTag,
Domain: dnsRecord,
WebhookToken: config.WebhookToken,
}); err != nil {
log.Printf("errors in notifications: %s", err)
}
log.Printf("Updated to `%s`", currentPublicIP)
}
}
@@ -121,23 +120,3 @@ func main() {
}
}
}
func allDNSRecords(ctx context.Context, api *cloudflare.API, rc *cloudflare.ResourceContainer) ([]cloudflare.DNSRecord, error) {
out := make([]cloudflare.DNSRecord, 0, 100)
params := cloudflare.ListDNSRecordsParams{
ResultInfo: cloudflare.ResultInfo{Page: 1},
}
for {
page, res, err := api.ListDNSRecords(ctx, rc, params)
if err != nil {
return nil, err
}
out = append(out, page...)
if res.Page >= res.TotalPages {
break
}
params.Page++
}
return out, nil
}