Skip to content

Commit

Permalink
feat(monitor): improve Healthchecks integration (#272)
Browse files Browse the repository at this point in the history
* feat(monitor): improve Healthchecks integration

1. Explicitly document that any instance of Healthchecks is supported
2. Warn about HTTP connections
3. Use POST method to work with the POST-only mode
4. Send summary for IP updating, record cleaning, and failures

* ci: improve coverage

* ci: improve coverage

* ci: improve coverage
  • Loading branch information
favonia committed Nov 16, 2022
1 parent 183921d commit b24cce6
Show file tree
Hide file tree
Showing 20 changed files with 458 additions and 279 deletions.
12 changes: 6 additions & 6 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ By default, public IP addresses are obtained using the [Cloudflare debugging pag

- 🛑 The superuser privileges are immediately dropped after the updater starts. This minimizes the impact of undiscovered security bugs in the updater.
- 🛡️ The updater uses HTTPS (or [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS)) to detect public IP addresses, making it harder to tamper with the detection process. _(Due to the nature of address detection, it is impossible to protect the updater from an adversary who can modify the source IP address of the IP packets coming from your machine.)_
- 🖥️ Optionally, you can [monitor the updater via Healthchecks.io](https://healthchecks.io), which will notify you when the updating fails.
- 🖥️ Optionally, you can [monitor the updater via Healthchecks](https://healthchecks.io), which will notify you when the updating fails.
- 📚 The updater uses only established open-source Go libraries.
<details><summary>🔌 Full list of external Go libraries <em>(click to expand)</em></summary>

Expand Down Expand Up @@ -433,12 +433,12 @@ In most cases, `CF_ACCOUNT_ID` is not needed.
<details>
<summary>👁️ Monitoring the updater</summary>

| Name | Valid Values | Meaning | Required? | Default Value |
| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------- | ------------- |
| `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>` (see below) | If set, the updater will ping the URL when it successfully updates IP addresses | No | (unset) |
| Name | Valid Values | Meaning | Required? | Default Value |
| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------- | --------- | ------------- |
| `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 ping URLs](https://healthchecks.io/docs/), such as `https://hc-ping.com/<uuid>` or `https://hc-ping.com/<project-ping-key>/<name-slug>` (see below) | If set, the updater will ping the URL when it successfully updates IP addresses | No | (unset) |

For `HEALTHCHECKS`, the updater accepts any URL that follows the [same notification protocol](https://healthchecks.io/docs/http_api/).
For `HEALTHCHECKS`, the updater can work with any server following the [same notification protocol](https://healthchecks.io/docs/http_api/), including but not limited to self-hosted instances of [Healthchecks](https://github.com/healthchecks/healthchecks). Both UUID and Slug URLs are supported, and the updater is compatible with POST-only mode.

</details>

Expand Down
68 changes: 32 additions & 36 deletions cmd/ddns.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
Expand Down Expand Up @@ -33,23 +34,22 @@ func signalWait(signal chan os.Signal, d time.Duration) (os.Signal, bool) {

var Version string //nolint:gochecknoglobals

func welcome(ppfmt pp.PP) {
func formatName() string {
if Version == "" {
ppfmt.Noticef(pp.EmojiStar, "Cloudflare DDNS")
} else {
ppfmt.Noticef(pp.EmojiStar, "Cloudflare DDNS (%s)", Version)
return "Cloudflare DDNS"
}
return fmt.Sprintf("Cloudflare DDNS (%s)", Version)
}

func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle, setter.Setter) {
c := config.Default()
bye := func() {
// Usually, this is called only after initConfig,
// but we are exiting early.
monitor.StartAll(ctx, ppfmt, c.Monitors)
monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())

ppfmt.Noticef(pp.EmojiBye, "Bye!")
monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1)
monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 1, "configuration errors")
os.Exit(1)
}

Expand All @@ -76,6 +76,17 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, api.Handle, s
return c, h, s
}

func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) {
if c.DeleteOnStop {
ppfmt.Noticef(pp.EmojiClearRecord, "Deleting all managed records . . .")
if ok, msg := updater.ClearIPs(ctx, ppfmt, c, s); ok {
monitor.LogAll(ctx, ppfmt, c.Monitors, msg)
} else {
monitor.FailureAll(ctx, ppfmt, c.Monitors, msg)
}
}
}

func main() { //nolint:funlen
ppfmt := pp.New(os.Stdout)
if !config.ReadQuiet("QUIET", &ppfmt) {
Expand All @@ -86,7 +97,7 @@ func main() { //nolint:funlen
ppfmt.Noticef(pp.EmojiMute, "Quiet mode enabled")
}

welcome(ppfmt)
ppfmt.Noticef(pp.EmojiStar, formatName())

// Drop the superuser privilege
dropPriviledges(ppfmt)
Expand All @@ -105,7 +116,7 @@ func main() { //nolint:funlen
c, h, s := initConfig(ctx, ppfmt)

// Start the tool now
monitor.StartAll(ctx, ppfmt, c.Monitors)
monitor.StartAll(ctx, ppfmt, c.Monitors, formatName())

first := true
mainLoop:
Expand All @@ -116,30 +127,22 @@ mainLoop:

// Update the IP
if !first || c.UpdateOnStart {
if updater.UpdateIPs(ctx, ppfmt, c, s) {
monitor.SuccessAll(ctx, ppfmt, c.Monitors)
if ok, msg := updater.UpdateIPs(ctx, ppfmt, c, s); ok {
monitor.SuccessAll(ctx, ppfmt, c.Monitors, msg)
} else {
monitor.FailureAll(ctx, ppfmt, c.Monitors)
monitor.FailureAll(ctx, ppfmt, c.Monitors, msg)
}
} else {
monitor.SuccessAll(ctx, ppfmt, c.Monitors)
monitor.SuccessAll(ctx, ppfmt, c.Monitors, "")
}
first = false

// Maybe there's nothing scheduled in near future?
if next.IsZero() {
if c.DeleteOnStop {
ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future. Deleting all managed records . . .")
if !updater.ClearIPs(ctx, ppfmt, c, s) {
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)
ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
stopUpdating(ctx, ppfmt, c, s)
ppfmt.Noticef(pp.EmojiBye, "Bye!")
monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, "Not scheduled updates")
break mainLoop
}

Expand Down Expand Up @@ -170,21 +173,14 @@ mainLoop:

ppfmt.Noticef(pp.EmojiRepeatOnce, "Restarting . . .")
c, h, s = initConfig(ctx, ppfmt)
monitor.LogAll(ctx, ppfmt, c.Monitors, "Restarted")
continue mainLoop

case syscall.SIGINT, syscall.SIGTERM:
if c.DeleteOnStop {
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v. Deleting all managed records . . .", sig)
if !updater.ClearIPs(ctx, ppfmt, c, s) {
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)
ppfmt.Noticef(pp.EmojiSignal, "Caught signal: %v", sig)
stopUpdating(ctx, ppfmt, c, s)
ppfmt.Noticef(pp.EmojiBye, "Bye!")
monitor.ExitStatusAll(ctx, ppfmt, c.Monitors, 0, fmt.Sprintf("Signal: %v", sig))
break mainLoop

default:
Expand Down
6 changes: 3 additions & 3 deletions internal/README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
- `config`: read configuration settings from environment variables
- `cron`: parse Cron expressions
- `domain`: handle domain names and split them into possible subdomains and zones
- `domainexp`: handle domain lists and parse boolean expressions on domains (for `PROXIED`)
- `file`: virtualize file system (to enable testing)
- `domainexp`: parse domain lists and parse boolean expressions on domains (for `PROXIED`)
- `file`: virtualize file systems (to enable testing)
- `ipnet`: define a type for labelling IPv4 and IPv6
- `monitor`: ping the monitoring API, currently only supporting Healthchecks.io
- `monitor`: ping the monitoring API, currently only supporting Healthchecks
- `pp`: pretty print messages with emojis
- `provider`: find out the public IP
- `setter`: set the IP of one domain using a DNS service API
Expand Down
2 changes: 1 addition & 1 deletion internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -264,7 +264,7 @@ func (c *Config) ReadEnv(ppfmt pp.PP) bool {
!ReadString(ppfmt, "PROXIED", &c.ProxiedTemplate) ||
!ReadNonnegDuration(ppfmt, "DETECTION_TIMEOUT", &c.DetectionTimeout) ||
!ReadNonnegDuration(ppfmt, "UPDATE_TIMEOUT", &c.UpdateTimeout) ||
!ReadHealthChecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) {
!ReadHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) {
return false
}

Expand Down
4 changes: 2 additions & 2 deletions internal/config/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -402,7 +402,7 @@ func TestPrintMaps(t *testing.T) {
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "IP detection:", "5s"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Record updating:", "30s"),
mockPP.EXPECT().Infof(pp.EmojiConfig, "Monitors:"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Healthchecks.io:", "(URL redacted)"),
innerMockPP.EXPECT().Infof(pp.EmojiBullet, "%-*s %s", 24, "Healthchecks:", "(URL redacted)"),
)

c := config.Default()
Expand All @@ -418,7 +418,7 @@ func TestPrintMaps(t *testing.T) {
c.Proxied[domain.FQDN("c")] = false
c.Proxied[domain.FQDN("d")] = false

m, ok := monitor.NewHealthChecks(mockPP, "http://user:pass@host/path")
m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
require.True(t, ok)
c.Monitors = []monitor.Monitor{m}

Expand Down
6 changes: 3 additions & 3 deletions internal/config/env.go
Original file line number Diff line number Diff line change
Expand Up @@ -282,15 +282,15 @@ func ReadCron(ppfmt pp.PP, key string, field *cron.Schedule) bool {
return true
}

// ReadHealthChecksURL reads the base URL of the healthcheck.io endpoint.
func ReadHealthChecksURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
// ReadHealthchecksURL reads the base URL of the healthcheck.io endpoint.
func ReadHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
val := Getenv(key)

if val == "" {
return true
}

h, ok := monitor.NewHealthChecks(ppfmt, val)
h, ok := monitor.NewHealthchecks(ppfmt, val)
if !ok {
return false
}
Expand Down
28 changes: 14 additions & 14 deletions internal/config/env_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -720,7 +720,7 @@ func urlMustParse(t *testing.T, u string) *url.URL {
}

//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global
func TestReadHealthChecksURL(t *testing.T) {
func TestReadHealthchecksURL(t *testing.T) {
key := keyPrefix + "HEALTHCHECKS"

type mon = monitor.Monitor
Expand All @@ -742,43 +742,43 @@ func TestReadHealthChecksURL(t *testing.T) {
"example": {
true, "https://hi.org/1234",
[]mon{},
[]mon{&monitor.HealthChecks{
[]mon{&monitor.Healthchecks{
BaseURL: urlMustParse(t, "https://hi.org/1234"),
Timeout: monitor.HealthChecksDefaultTimeout,
MaxRetries: monitor.HealthChecksDefaultMaxRetries,
Timeout: monitor.HealthchecksDefaultTimeout,
MaxRetries: monitor.HealthchecksDefaultMaxRetries,
}},
true,
nil,
},
"password": {
true, "https://me:pass@hi.org/1234",
[]mon{},
[]mon{&monitor.HealthChecks{
[]mon{&monitor.Healthchecks{
BaseURL: urlMustParse(t, "https://me:pass@hi.org/1234"),
Timeout: monitor.HealthChecksDefaultTimeout,
MaxRetries: monitor.HealthChecksDefaultMaxRetries,
Timeout: monitor.HealthchecksDefaultTimeout,
MaxRetries: monitor.HealthchecksDefaultMaxRetries,
}},
true,
nil,
},
"fragment": {
true, "https://hi.org/1234#fragment",
[]mon{},
[]mon{&monitor.HealthChecks{
[]mon{&monitor.Healthchecks{
BaseURL: urlMustParse(t, "https://hi.org/1234#fragment"),
Timeout: monitor.HealthChecksDefaultTimeout,
MaxRetries: monitor.HealthChecksDefaultMaxRetries,
Timeout: monitor.HealthchecksDefaultTimeout,
MaxRetries: monitor.HealthchecksDefaultMaxRetries,
}},
true,
nil,
},
"query": {
true, "https://hi.org/1234?hello=123",
[]mon{},
[]mon{&monitor.HealthChecks{
[]mon{&monitor.Healthchecks{
BaseURL: urlMustParse(t, "https://hi.org/1234?hello=123"),
Timeout: monitor.HealthChecksDefaultTimeout,
MaxRetries: monitor.HealthChecksDefaultMaxRetries,
Timeout: monitor.HealthchecksDefaultTimeout,
MaxRetries: monitor.HealthchecksDefaultMaxRetries,
}},
true,
nil,
Expand All @@ -793,7 +793,7 @@ func TestReadHealthChecksURL(t *testing.T) {
if tc.prepareMockPP != nil {
tc.prepareMockPP(mockPP)
}
ok := config.ReadHealthChecksURL(mockPP, key, &field)
ok := config.ReadHealthchecksURL(mockPP, key, &field)
require.Equal(t, tc.ok, ok)
require.Equal(t, tc.newField, field)
})
Expand Down
46 changes: 30 additions & 16 deletions internal/mocks/mock_monitor.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit b24cce6

Please sign in to comment.