12 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
22 changed files with 518 additions and 129 deletions

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.21 as build
FROM golang:1.22 as build
# Copy project sources
COPY . /opt/project/
WORKDIR /opt/project
# 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

@@ -12,7 +12,7 @@ This simple updater do the job, and send notifications, if change happen.
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
@@ -31,7 +31,8 @@ Request body example:
"new_ip": "xxx.xxx.xxx.xxx",
"checked_at": "2023-05-04T17:39:42.942850354+02:00",
"resolver_tag": "ifconfig.me",
"domain": "my.domain.com"
"domain": "my.domain.com",
"token": "a-webhook-token"
}
```
@@ -57,20 +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
- DNS_NAMES=my.testdomain.com,your.testdomain.com
- NOTIFIERS=webhook@http://localhost/cloudflare-updated-notification
- CLOUDFLARE_ZONE=testdomain.com
- 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`

13
go.mod
View File

@@ -1,14 +1,21 @@
module github.com/mkelcik/cloudflare-ddns-update
module hub.cybercinch.nz/cybercinch/ddns-update
go 1.21
require github.com/cloudflare/cloudflare-go v0.83.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.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

35
go.sum
View File

@@ -1,43 +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=
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.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/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.3.0 h1:rg5rLMjNzMS1RkNLzCG38eapWhnYLFYXDXj2gOlr8j4=
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=
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
}