Skip to content

Commit

Permalink
feat(monitor): support healthchecks.io (#160)
Browse files Browse the repository at this point in the history
* feat(monitor): support healthchecks.io

* test(config): test ReadHealthChecksURL and Config.Print

* test(monitor): test part of healthchecks.go

* test(monitor): test NewHealthChecks

* test(monitor): cover more cases

* test(monitor): cover more cases

* refactor(detector): policy.go => base.go

* test(monitor): test monitor/util.go

* style(monitor): make linter happy

* feat(monitor): implement exponential backoff

* docs(README): document new option HEALTHCHECKS

* docs(README): further tweaks

* docs(README): further tweaks

* docs(README): further tweaks

* docs(README): further tweaks

* style(monitor): make linter happy

* test(monitor): test SetHealthChecksMaxRetries
  • Loading branch information
favonia committed May 9, 2022
1 parent b3fc809 commit f83f5fb
Show file tree
Hide file tree
Showing 14 changed files with 889 additions and 64 deletions.
30 changes: 16 additions & 14 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ A small and fast DDNS updater for Cloudflare.
* Full configurability via environment variables.
* Ability to pass API tokens via a file instead of an environment variable.
* Local caching to reduce Cloudflare API usage.
* Integration with [Healthchecks.io](https://healthchecks.io).

## 🕵️ Privacy

Expand Down Expand Up @@ -291,15 +292,13 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
| ---- | ------------ | ------- | --------- | ------------- |
| `DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains this tool should manage | (See below) | N/A
| `IP4_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains this tool should manage for `A` records | (See below) | N/A
| `IP4_POLICY` | `cloudflare`, `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `unmanaged` | How to detect IPv4 addresses. (See below) | No | `cloudflare.trace`
| `IP4_POLICY` | `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `unmanaged` | How to detect IPv4 addresses. (See below) | No | `cloudflare.trace`
| `IP6_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names | The domains this tool should manage for `AAAA` records | (See below) | N/A
| `IP6_POLICY` | `cloudflare`, `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `unmanaged` | How to detect IPv6 addresses. (See below) | No | `cloudflare.trace`
| `IP6_POLICY` | `cloudflare.doh`, `cloudflare.trace`, `ipify`, `local`, and `unmanaged` | How to detect IPv6 addresses. (See below) | No | `cloudflare.trace`

> <details>
> <summary>📜 Available policies for <code>IP4_POLICY</code> and <code>IP6_POLICY</code></summary>
>
> - `cloudflare`\
> Deprecated; currently an alias of `cloudflare.trace`.
> - `cloudflare.doh`\
> Get the public IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https) and update DNS records accordingly.
> - `cloudflare.trace`\
Expand All @@ -310,6 +309,8 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
> Get the address via local network interfaces and update DNS records accordingly. When multiple local network interfaces or in general multiple IP addresses are present, the tool will use the address that would have been used for outbound UDP connections to Cloudflare servers. ⚠️ You need access to the host network (such as `network_mode: host` in Docker Compose or `hostNetwork: true` in Kubernetes) for this policy, for otherwise the tool will detect the addresses inside the [bridge network in Docker](https://docs.docker.com/network/bridge/) or the [default namespaces in Kubernetes](https://kubernetes.io/docs/concepts/overview/working-with-objects/namespaces/) instead of those in the host network.
> - `unmanaged`\
> Stop the DNS updating completely. Existing DNS records will not be removed.
> - `cloudflare`\
> Deprecated; currently an alias of `cloudflare.trace`.
>
> The option `IP4_POLICY` is governing IPv4 addresses and `A`-type records, while the option `IP6_POLICY` is governing IPv6 addresses and `AAAA`-type records. The two options act independently of each other.
> </details>
Expand All @@ -327,15 +328,15 @@ In most cases, `CF_ACCOUNT_ID` is not needed.

| Name | Valid Values | Meaning | Required? | Default Value |
| ---- | ------------ | ------- | --------- | ------------- |
| `CACHE_EXPIRATION` | Positive time duration with a unit, such as `1h` or `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The expiration of cached Cloudflare API responses | No | `6h0m0s` (6 hours)
| `DELETE_ON_STOP` | `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, and `False` | Whether managed DNS records should be deleted on exit | No | `false`
| `DETECTION_TIMEOUT` | Positive time duration with a unit, such as `1h` or `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to detect IP addresses | No | `5s` (5 seconds)
| `PROXIED` | `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, and `False` | Whether new DNS records should be proxied by Cloudflare | No | `false`
| `CACHE_EXPIRATION` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The expiration of cached Cloudflare API responses | No | `6h0m0s` (6 hours)
| `DELETE_ON_STOP` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether managed DNS records should be deleted on exit | No | `false`
| `DETECTION_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to detect IP addresses | No | `5s` (5 seconds)
| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether new DNS records should be proxied by Cloudflare | No | `false`
| `TTL` | Time-to-live (TTL) values in seconds | The TTL values used to create new DNS records | No | `1` (This means “automatic” to Cloudflare)
| `TZ` | Recognized timezones, such as `UTC` | The timezone used for logging and parsing `UPDATE_CRON` | No | `UTC`
| `UPDATE_CRON` | Cron expressions; [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format). | The schedule to re-check IP addresses and update DNS records (if necessary) | No | `@every 5m` (every 5 minutes)
| `UPDATE_ON_START` | `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, and `False` | Whether to check IP addresses on start regardless of `UPDATE_CRON` | No | `true`
| `UPDATE_TIMEOUT` | Positive time duration with a unit, such as `1h` or `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to update DNS records, per domain, per record type | No | `30s` (30 seconds)
| `UPDATE_CRON` | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format) | The schedule to re-check IP addresses and update DNS records (if necessary) | No | `@every 5m` (every 5 minutes)
| `UPDATE_ON_START` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether to check IP addresses on start regardless of `UPDATE_CRON` | No | `true`
| `UPDATE_TIMEOUT` | Positive time durations with a unit, such as `1h` and `10m`. See [time.ParseDuration](https://golang.org/pkg/time/#ParseDuration) | The timeout of each attempt to update DNS records, per domain, per record type | No | `30s` (30 seconds)

Note that the update schedule _does not_ take the time to update records into consideration. For example, if the schedule is “for every 5 minutes”, and if the updating itself takes 2 minutes, then the actual interval between adjacent updates is 3 minutes, not 5 minutes.
</details>
Expand All @@ -352,14 +353,15 @@ The updater will also try to drop supplementary group IDs.
</details>

<details>
<summary>🔇 Quiet mode</summary>
<summary>👁️ Monitoring the tool</summary>

| Name | Valid Values | Meaning | Required? | Default Value |
| ---- | ------------ | ------- | --------- | ------------- |
| `QUIET` | `1`, `t`, `T`, `TRUE`, `true`, `True`, `0`, `f`, `F`, `FALSE`, `false`, and `False` | Whether the updater should reduce the logging | No | `false`
| `QUIET` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool) | Whether the updater should reduce the logging to the standard output | No | `false`
| `HEALTHCHECKS` | [Healthchecks.io ping URLs,](https://healthchecks.io/docs/) such as `https://hc-ping.com/<uuid>` or `https://hc-ping.com/<project-ping-key>/<name-slug>` | If set, the tool will ping Healthchecks.io when it successfully updates IP addresses | No | N/A
</details>

### 🔁 Restarting the Container
### 🔂 Restarting the Container

If you are using Docker Compose, run `docker-compose up --detach` after changing the settings.

Expand Down
70 changes: 42 additions & 28 deletions cmd/ddns.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/favonia/cloudflare-ddns/internal/api"
"github.com/favonia/cloudflare-ddns/internal/config"
"github.com/favonia/cloudflare-ddns/internal/monitor"
"github.com/favonia/cloudflare-ddns/internal/pp"
)

Expand Down Expand Up @@ -46,8 +47,9 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle) {
ppfmt.Noticef(pp.EmojiBye, "Bye!")
os.Exit(1)
}
if !c.Normalize(ppfmt) {
if !c.NormalizeDomains(ppfmt) {
ppfmt.Noticef(pp.EmojiBye, "Bye!")
monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1)
os.Exit(1)
}

Expand All @@ -57,13 +59,14 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle) {
h, ok := c.Auth.New(ctx, ppfmt, c.CacheExpiration)
if !ok {
ppfmt.Noticef(pp.EmojiBye, "Bye!")
monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1)
os.Exit(1)
}

return c, h
}

func main() { //nolint:funlen,cyclop
func main() { //nolint:funlen,cyclop,gocognit
ppfmt := pp.New(os.Stdout)
if !config.ReadQuiet("QUIET", &ppfmt) {
ppfmt.Noticef(pp.EmojiUserError, "Bye!")
Expand All @@ -90,26 +93,34 @@ func main() { //nolint:funlen,cyclop

// reading the config
c, h := initConfig(ctx, ppfmt)
monitor.StartAll(ctx, ppfmt, c.Monitors)

first := true
mainLoop:
for {
next := c.UpdateCron.Next()
if !first || c.UpdateOnStart {
updateIPs(ctx, ppfmt, c, h)
if updateIPs(ctx, ppfmt, c, h) {
monitor.SuccessAll(ctx, ppfmt, c.Monitors)
} else {
monitor.FailureAll(ctx, ppfmt, c.Monitors)
}
}
first = false

if next.IsZero() {
if c.DeleteOnStop {
ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future. Deleting all managed records . . .")
clearIPs(ctx, ppfmt, c, h)
if !clearIPs(ctx, ppfmt, c, h) {
monitor.FailureAll(ctx, ppfmt, c.Monitors)
}
ppfmt.Noticef(pp.EmojiBye, "Done now. Bye!")
} else {
ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
ppfmt.Noticef(pp.EmojiBye, "Bye!")
}

monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0)
break mainLoop
}

Expand All @@ -126,34 +137,37 @@ mainLoop:
ppfmt.Infof(pp.EmojiAlarm, "Checking the IP addresses in about %v . . .", interval.Round(IntervalUnit))
}

if sig, ok := signalWait(chanSignal, interval); !ok {
sig, ok := signalWait(chanSignal, interval)
if !ok {
continue mainLoop
} else {
switch sig.(syscall.Signal) { //nolint:forcetypeassert
case syscall.SIGHUP:
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
h.FlushCache()

ppfmt.Noticef(pp.EmojiNow, "Restarting . . .")
c, h = initConfig(ctx, ppfmt)
continue mainLoop

case syscall.SIGINT, syscall.SIGTERM:
if c.DeleteOnStop {
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v. Deleting all managed records . . .", sig)
clearIPs(ctx, ppfmt, c, h)
ppfmt.Noticef(pp.EmojiBye, "Done now. Bye!")
} else {
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
ppfmt.Noticef(pp.EmojiBye, "Bye!")
}
}
switch sig.(syscall.Signal) { //nolint:forcetypeassert
case syscall.SIGHUP:
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
h.FlushCache()

break mainLoop
ppfmt.Noticef(pp.EmojiRepeatOnce, "Restarting . . .")
c, h = initConfig(ctx, ppfmt)
continue mainLoop

default:
ppfmt.Noticef(pp.EmojiSignal, "Caught and ignored unexpected signal: %v", sig)
continue mainLoop
case syscall.SIGINT, syscall.SIGTERM:
if c.DeleteOnStop {
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v. Deleting all managed records . . .", sig)
if !clearIPs(ctx, ppfmt, c, h) {
monitor.FailureAll(ctx, ppfmt, c.Monitors)
}
ppfmt.Noticef(pp.EmojiBye, "Done now. Bye!")
} else {
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
ppfmt.Noticef(pp.EmojiBye, "Bye!")
}

monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0)
break mainLoop

default:
ppfmt.Noticef(pp.EmojiSignal, "Caught and ignored unexpected signal: %v", sig)
continue mainLoop
}
}
}
52 changes: 37 additions & 15 deletions cmd/ip.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,21 +11,28 @@ import (
"github.com/favonia/cloudflare-ddns/internal/updator"
)

func setIP(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle, ipNet ipnet.Type, ip netip.Addr) {
func setIP(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle, ipNet ipnet.Type, ip netip.Addr) bool {
ok := true

for _, target := range c.Domains[ipNet] {
ctx, cancel := context.WithTimeout(ctx, c.UpdateTimeout)
defer cancel()

_ = updator.Do(ctx, ppfmt,
&updator.Args{
Handle: h,
Domain: target,
IPNetwork: ipNet,
IP: ip,
TTL: c.TTL,
Proxied: c.Proxied,
})
args := &updator.Args{
Handle: h,
Domain: target,
IPNetwork: ipNet,
IP: ip,
TTL: c.TTL,
Proxied: c.Proxied,
}

if !updator.Do(ctx, ppfmt, args) {
ok = false
}
}

return ok
}

var ipv6MessageDisplayed = false //nolint:gochecknoglobals
Expand All @@ -48,21 +55,36 @@ func detectIP(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle,
return ip
}

func updateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle) {
func updateIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle) bool {
ok := true

for _, ipNet := range []ipnet.Type{ipnet.IP4, ipnet.IP6} {
if c.Policy[ipNet] != nil {
ip := detectIP(ctx, ppfmt, c, h, ipNet)
if ip.IsValid() {
setIP(ctx, ppfmt, c, h, ipNet, ip)
if !ip.IsValid() {
ok = false
continue
}

if !setIP(ctx, ppfmt, c, h, ipNet, ip) {
ok = false
}
}
}

return ok
}

func clearIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle) {
func clearIPs(ctx context.Context, ppfmt pp.PP, c *config.Config, h api.Handle) bool {
ok := true

for _, ipNet := range []ipnet.Type{ipnet.IP4, ipnet.IP6} {
if c.Policy[ipNet] != nil {
setIP(ctx, ppfmt, c, h, ipNet, netip.Addr{})
if !setIP(ctx, ppfmt, c, h, ipNet, netip.Addr{}) {
ok = false
}
}
}

return ok
}
19 changes: 16 additions & 3 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import (
"github.com/favonia/cloudflare-ddns/internal/detector"
"github.com/favonia/cloudflare-ddns/internal/file"
"github.com/favonia/cloudflare-ddns/internal/ipnet"
"github.com/favonia/cloudflare-ddns/internal/monitor"
"github.com/favonia/cloudflare-ddns/internal/pp"
)

Expand All @@ -23,6 +24,7 @@ type Config struct {
Proxied bool
DetectionTimeout time.Duration
UpdateTimeout time.Duration
Monitors []monitor.Monitor
}

// Default gives default values.
Expand All @@ -45,6 +47,7 @@ func Default() *Config {
Proxied: false,
UpdateTimeout: time.Second * 30, //nolint:gomnd
DetectionTimeout: time.Second * 5, //nolint:gomnd
Monitors: nil,
}
}

Expand Down Expand Up @@ -191,9 +194,18 @@ func (c *Config) Print(ppfmt pp.PP) {
inner.Infof(pp.EmojiBullet, "TTL: %s", c.TTL.Describe())
inner.Infof(pp.EmojiBullet, "Proxied: %t", c.Proxied)

ppfmt.Infof(pp.EmojiConfig, "Timeouts")
ppfmt.Infof(pp.EmojiConfig, "Timeouts:")
inner.Infof(pp.EmojiBullet, "IP detection: %v", c.DetectionTimeout)
inner.Infof(pp.EmojiBullet, "Record updating: %v", c.UpdateTimeout)

if len(c.Monitors) > 0 {
ppfmt.Infof(pp.EmojiConfig, "Monitors:")
for _, m := range c.Monitors {
inner.Infof(pp.EmojiBullet, "%-17s %v", m.DescribeService()+":", m.DescribeBaseURL())
}
} else {
ppfmt.Infof(pp.EmojiConfig, "Monitors: (none)")
}
}

func (c *Config) ReadEnv(ppfmt pp.PP) bool { //nolint:cyclop
Expand All @@ -212,7 +224,8 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool { //nolint:cyclop
!ReadNonnegInt(ppfmt, "TTL", (*int)(&c.TTL)) ||
!ReadBool(ppfmt, "PROXIED", &c.Proxied) ||
!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) {
!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
!ReadHealthChecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) {
return false
}

Expand Down Expand Up @@ -240,7 +253,7 @@ func (c *Config) checkUselessDomains(ppfmt pp.PP) {
}
}

func (c *Config) Normalize(ppfmt pp.PP) bool {
func (c *Config) NormalizeDomains(ppfmt pp.PP) bool {
if len(c.Domains[ipnet.IP4]) == 0 && len(c.Domains[ipnet.IP6]) == 0 {
ppfmt.Errorf(pp.EmojiUserError, "No domains were specified")
return false
Expand Down
Loading

0 comments on commit f83f5fb

Please sign in to comment.