Repository: favonia/cloudflare-ddns
Pull request #497 (2023-05-23 09:06:40)

feah(monitor): add basic support for Uptime Kuma
Also reject non-empty query strings in Healthchecks URLs


Patch:
diff --git a/README.markdown b/README.markdown
index 2313363..71d762b 100644
--- a/README.markdown
+++ b/README.markdown
@@ -251,15 +251,15 @@ _(Click to expand the following items.)_
 <details>
 <summary>⏳ Schedules, triggers, and timeouts</summary>
 
-| Name                | Valid Values                                                                                                                                                                     | Meaning                                                                        | Required? | Default Value                 |
-| ------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------- | ----------------------------- |
-| `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)              |
-| `TZ`                | Recognized timezones, such as `UTC`                                                                                                                                              | The timezone used for logging and parsing `UPDATE_CRON`                        | No        | `UTC`                         |
-| `UPDATE_CRON`       | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format). See below for the experimental mode to disable cron. | 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)            |
+| Name                | Valid Values                                                                                                                                                                        | Meaning                                                                        | Required? | Default Value                 |
+| ------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------ | --------- | ----------------------------- |
+| `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)              |
+| `TZ`                | Recognized timezones, such as `UTC`                                                                                                                                                 | The timezone used for logging and parsing `UPDATE_CRON`                        | No        | `UTC`                         |
+| `UPDATE_CRON`       | Cron expressions. See the [documentation of cron](https://pkg.go.dev/github.com/robfig/cron/v3#hdr-CRON_Expression_Format). 🧪 See below for the experimental mode to disable cron. | 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)            |
 
 > ⚠️ 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.
 
@@ -270,10 +270,10 @@ _(Click to expand the following items.)_
 <details>
 <summary>🐣 Parameters of new DNS records</summary>
 
-| Name      | Valid Values                                                                                                                                                                          | Meaning                                                 | Required? | Default Value                              |
-| --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------- | ------------------------------------------ |
-| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool). See below for experimental support of per-domain proxy settings. | 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) |
+| Name      | Valid Values                                                                                                                                                                             | Meaning                                                 | Required? | Default Value                              |
+| --------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------- | --------- | ------------------------------------------ |
+| `PROXIED` | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool). 🧪 See below for experimental support of per-domain proxy settings. | 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) |
 
 > 👉 The updater will preserve existing proxy and TTL settings until it has to create new DNS records (or recreate deleted ones). Only when it creates DNS records, the above settings will apply. To change existing proxy and TTL settings now, you can go to your [Cloudflare Dashboard](https://dash.cloudflare.com) and change them directly. If you think you have a use case where the updater should actively overwrite existing proxy and TTL settings in addition to IP addresses, please [let me know](https://github.com/favonia/cloudflare-ddns/issues/new). It is not hard to implement optional overwriting.
 
@@ -323,15 +323,16 @@ _(Click to expand the following items.)_
 </details>
 
 <details>
-<summary>👁️ Logging and Healthchecks</summary>
+<summary>👁️ Logging, Healthchecks, and Uptime Kuma</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                                   | No        | `false`       |
-| `EMOJI`        | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                               | Whether the updater should use emojis in the logging                            | No        | `true`        |
-| `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)       |
+| 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                                      | No        | `false`       |
+| `EMOJI`                        | Boolean values, such as `true`, `false`, `0` and `1`. See [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool)                                               | Whether the updater should use emojis in the logging                               | No        | `true`        |
+| `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)       |
+| 🧪 `UPTIMEKUMA` (experimental) | 🧪 Uptime Kuma’s Push URLs, such as `https://<host>/push/<id>`. One can directly copy the ‘Push URL’ from the Uptime Kuma configuration page.                     | 🧪 If set, the updater will ping the URL when it successfully updates IP addresses | No        | (unset)       |
 
-> 🩺 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 automatically supports the POST-only mode.
+> 🩺 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 works regardless whether the POST-only mode is enabled.
 
 </details>
 
diff --git a/cmd/ddns/ddns.go b/cmd/ddns/ddns.go
index 7fc275a..7ea684e 100644
--- a/cmd/ddns/ddns.go
+++ b/cmd/ddns/ddns.go
@@ -10,6 +10,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/droproot"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/setter"
 	"github.com/favonia/cloudflare-ddns/internal/signal"
@@ -56,14 +57,15 @@ func initConfig(ctx context.Context, ppfmt pp.PP) (*config.Config, setter.Setter
 func stopUpdating(ctx context.Context, ppfmt pp.PP, c *config.Config, s setter.Setter) {
 	if c.DeleteOnStop {
 		if ok, msg := updater.ClearIPs(ctx, ppfmt, c, s); ok {
-			c.Monitor.Log(ctx, ppfmt, msg)
+			monitor.LogAll(ctx, ppfmt, msg, c.Monitors)
 		} else {
-			c.Monitor.Failure(ctx, ppfmt, msg)
+			monitor.FailureAll(ctx, ppfmt, msg, c.Monitors)
 		}
 	}
 }
 
 func main() {
+	// This is to make os.Exit work with defer
 	os.Exit(realMain())
 }
 
@@ -93,10 +95,10 @@ func realMain() int { //nolint:funlen
 	// Read the config and get the handler and the setter
 	c, s, configOk := initConfig(ctx, ppfmt)
 	// Ping the monitor regardless of whether initConfig succeeded
-	c.Monitor.Start(ctx, ppfmt, formatName())
+	monitor.StartAll(ctx, ppfmt, formatName(), c.Monitors)
 	// Bail out now if initConfig failed
 	if !configOk {
-		c.Monitor.ExitStatus(ctx, ppfmt, 1, "Config errors")
+		monitor.ExitStatusAll(ctx, ppfmt, 1, "Config errors", c.Monitors)
 		ppfmt.Infof(pp.EmojiBye, "Bye!")
 		return 1
 	}
@@ -118,12 +120,12 @@ func realMain() int { //nolint:funlen
 
 		// Update the IP addresses
 		if first && !c.UpdateOnStart {
-			c.Monitor.Success(ctx, ppfmt, "Started (no action)")
+			monitor.SuccessAll(ctx, ppfmt, "Started (no action)", c.Monitors)
 		} else {
 			if ok, msg := updater.UpdateIPs(ctxWithSignals, ppfmt, c, s); ok {
-				c.Monitor.Success(ctx, ppfmt, msg)
+				monitor.SuccessAll(ctx, ppfmt, msg, c.Monitors)
 			} else {
-				c.Monitor.Failure(ctx, ppfmt, msg)
+				monitor.FailureAll(ctx, ppfmt, msg, c.Monitors)
 			}
 		}
 
@@ -135,11 +137,11 @@ func realMain() int { //nolint:funlen
 
 		first = false
 
-		// Maybe there's nothing scheduled in near future?
+		// If there's nothing scheduled in near future
 		if next.IsZero() {
 			ppfmt.Errorf(pp.EmojiUserError, "No scheduled updates in near future")
 			stopUpdating(ctx, ppfmt, c, s)
-			c.Monitor.ExitStatus(ctx, ppfmt, 1, "No scheduled updates")
+			monitor.ExitStatusAll(ctx, ppfmt, 1, "No scheduled updates", c.Monitors)
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 1
 		}
@@ -151,7 +153,7 @@ func realMain() int { //nolint:funlen
 		// Wait for the next signal or the alarm, whichever comes first
 		if !sig.Sleep(ppfmt, interval) {
 			stopUpdating(ctx, ppfmt, c, s)
-			c.Monitor.ExitStatus(ctx, ppfmt, 0, "Terminated")
+			monitor.ExitStatusAll(ctx, ppfmt, 0, "Terminated", c.Monitors)
 			ppfmt.Infof(pp.EmojiBye, "Bye!")
 			return 0
 		}
diff --git a/internal/config/config.go b/internal/config/config.go
index 77d4c63..681e2c6 100644
--- a/internal/config/config.go
+++ b/internal/config/config.go
@@ -28,7 +28,7 @@ type Config struct {
 	Proxied          map[domain.Domain]bool
 	DetectionTimeout time.Duration
 	UpdateTimeout    time.Duration
-	Monitor          monitor.Monitor
+	Monitors         []monitor.Monitor
 }
 
 // Default gives the default configuration.
@@ -53,6 +53,6 @@ func Default() *Config {
 		Proxied:          map[domain.Domain]bool{},
 		UpdateTimeout:    time.Second * 30, //nolint:gomnd
 		DetectionTimeout: time.Second * 5,  //nolint:gomnd
-		Monitor:          monitor.Monitors{},
+		Monitors:         nil,
 	}
 }
diff --git a/internal/config/config_print.go b/internal/config/config_print.go
index 8ea8127..e45935a 100644
--- a/internal/config/config_print.go
+++ b/internal/config/config_print.go
@@ -9,6 +9,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/domain"
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
 )
@@ -87,16 +88,10 @@ func (c *Config) Print(ppfmt pp.PP) {
 	item("IP detection:", "%v", c.DetectionTimeout)
 	item("Record updating:", "%v", c.UpdateTimeout)
 
-	var monitors [][2]string
-	if c.Monitor != nil {
-		c.Monitor.Describe(func(service, params string) {
-			monitors = append(monitors, [2]string{service, params})
-		})
-	}
-	if len(monitors) > 0 {
+	if len(c.Monitors) > 0 {
 		section("Monitors:")
-		for _, m := range monitors {
-			item(m[0]+":", "%s", m[1])
-		}
+		monitor.DescribeAll(func(service, params string) {
+			item(service+":", "%s", params)
+		}, c.Monitors)
 	}
 }
diff --git a/internal/config/config_read.go b/internal/config/config_read.go
index 871a147..b01be06 100644
--- a/internal/config/config_read.go
+++ b/internal/config/config_read.go
@@ -28,7 +28,8 @@ 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.Monitor) {
+		!ReadAndAppendHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) ||
+		!ReadAndAppendUptimeKumaURL(ppfmt, "UPTIMEKUMA", &c.Monitors) {
 		return false
 	}
 
diff --git a/internal/config/env_base.go b/internal/config/env_base.go
index 0921974..d106d84 100644
--- a/internal/config/env_base.go
+++ b/internal/config/env_base.go
@@ -8,7 +8,6 @@ import (
 
 	"github.com/favonia/cloudflare-ddns/internal/api"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
-	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
@@ -210,21 +209,3 @@ func ReadCron(ppfmt pp.PP, key string, field *cron.Schedule) bool {
 	*field = c
 	return true
 }
-
-// ReadHealthchecksURL reads the base URL of the Healthchecks 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)
-	if !ok {
-		return false
-	}
-
-	// Append the new monitor to the existing list
-	*field = h
-	return true
-}
diff --git a/internal/config/env_monitor.go b/internal/config/env_monitor.go
new file mode 100644
index 0000000..d8643e7
--- /dev/null
+++ b/internal/config/env_monitor.go
@@ -0,0 +1,42 @@
+package config
+
+import (
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+// ReadAndAppendHealthchecksURL reads the base URL of a Healthchecks endpoint.
+func ReadAndAppendHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
+	val := Getenv(key)
+
+	if val == "" {
+		return true
+	}
+
+	h, ok := monitor.NewHealthchecks(ppfmt, val)
+	if !ok {
+		return false
+	}
+
+	// Append the new monitor to the existing list
+	*field = append(*field, h)
+	return true
+}
+
+// ReadAndAppendUptimeKumaURL reads the URL of a Push Monitor of an Uptime Kuma server.
+func ReadAndAppendUptimeKumaURL(ppfmt pp.PP, key string, field *[]monitor.Monitor) bool {
+	val := Getenv(key)
+
+	if val == "" {
+		return true
+	}
+
+	h, ok := monitor.NewUptimeKuma(ppfmt, val)
+	if !ok {
+		return false
+	}
+
+	// Append the new monitor to the existing list
+	*field = append(*field, h)
+	return true
+}
diff --git a/internal/monitor/composite.go b/internal/monitor/composite.go
index 6908021..a6e5aac 100644
--- a/internal/monitor/composite.go
+++ b/internal/monitor/composite.go
@@ -6,18 +6,15 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
-// Monitors is the composite monitor that will notify a group of monitors simultaneously.
-type Monitors []Monitor
-
-// Describe calls [Monitor.Describe] for each monitor in the group with the callback.
-func (ms Monitors) Describe(callback func(service, params string)) {
+// DescribeAll calls [Monitor.Describe] for each monitor in the group with the callback.
+func DescribeAll(callback func(service, params string), ms []Monitor) {
 	for _, m := range ms {
 		m.Describe(callback)
 	}
 }
 
-// Success calls [Monitor.Success] for each monitor in the group.
-func (ms Monitors) Success(ctx context.Context, ppfmt pp.PP, message string) bool {
+// SuccessAll calls [Monitor.Success] for each monitor in the group.
+func SuccessAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Success(ctx, ppfmt, message) {
@@ -27,8 +24,8 @@ func (ms Monitors) Success(ctx context.Context, ppfmt pp.PP, message string) boo
 	return ok
 }
 
-// Start calls [Monitor.Start] for each monitor in the group.
-func (ms Monitors) Start(ctx context.Context, ppfmt pp.PP, message string) bool {
+// StartAll calls [Monitor.Start] for each monitor in ms.
+func StartAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Start(ctx, ppfmt, message) {
@@ -38,8 +35,8 @@ func (ms Monitors) Start(ctx context.Context, ppfmt pp.PP, message string) bool
 	return ok
 }
 
-// Failure calls [Monitor.Failure] for each monitor in the group.
-func (ms Monitors) Failure(ctx context.Context, ppfmt pp.PP, message string) bool {
+// FailureAll calls [Monitor.Failure] for each monitor in ms.
+func FailureAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Failure(ctx, ppfmt, message) {
@@ -49,8 +46,8 @@ func (ms Monitors) Failure(ctx context.Context, ppfmt pp.PP, message string) boo
 	return ok
 }
 
-// Log calls [Monitor.Log] for each monitor in the group.
-func (ms Monitors) Log(ctx context.Context, ppfmt pp.PP, message string) bool {
+// LogAll calls [Monitor.Log] for each monitor in ms.
+func LogAll(ctx context.Context, ppfmt pp.PP, message string, ms []Monitor) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.Log(ctx, ppfmt, message) {
@@ -60,8 +57,8 @@ func (ms Monitors) Log(ctx context.Context, ppfmt pp.PP, message string) bool {
 	return ok
 }
 
-// ExitStatus calls [Monitor.ExitStatus] for each monitor in the group.
-func (ms Monitors) ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool {
+// ExitStatusAll calls [Monitor.ExitStatus] for each monitor in ms.
+func ExitStatusAll(ctx context.Context, ppfmt pp.PP, code int, message string, ms []Monitor) bool {
 	ok := true
 	for _, m := range ms {
 		if !m.ExitStatus(ctx, ppfmt, code, message) {
diff --git a/internal/monitor/healthchecks.go b/internal/monitor/healthchecks.go
index acbf364..89e4a5e 100644
--- a/internal/monitor/healthchecks.go
+++ b/internal/monitor/healthchecks.go
@@ -33,19 +33,19 @@ const (
 // NewHealthchecks creates a new Healthchecks monitor.
 // See https://healthchecks.io/docs/http_api/ for more information.
 func NewHealthchecks(ppfmt pp.PP, rawURL string) (Monitor, bool) {
-	url, err := url.Parse(rawURL)
+	u, err := url.Parse(rawURL)
 	if err != nil {
 		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse the Healthchecks URL (redacted)")
 		return nil, false
 	}
 
-	if !(url.IsAbs() && url.Opaque == "" && url.Host != "") {
+	if !(u.IsAbs() && u.Opaque == "" && u.Host != "" && u.RawQuery == "") {
 		ppfmt.Errorf(pp.EmojiUserError, `The Healthchecks URL (redacted) does not look like a valid URL`)
 		ppfmt.Errorf(pp.EmojiUserError, `A valid example is "https://hc-ping.com/01234567-0123-0123-0123-0123456789abc"`)
 		return nil, false
 	}
 
-	switch url.Scheme {
+	switch u.Scheme {
 	case "http":
 		ppfmt.Warningf(pp.EmojiUserWarning, "The Healthchecks URL (redacted) uses HTTP; please consider using HTTPS")
 
@@ -59,14 +59,14 @@ func NewHealthchecks(ppfmt pp.PP, rawURL string) (Monitor, bool) {
 	}
 
 	h := &Healthchecks{
-		BaseURL: url,
+		BaseURL: u,
 		Timeout: HealthchecksDefaultTimeout,
 	}
 
 	return h, true
 }
 
-// Describe calles the callback with the service name "Healthchecks".
+// Describe calls the callback with the service name "Healthchecks".
 func (h *Healthchecks) Describe(callback func(service, params string)) {
 	callback("Healthchecks", "(URL redacted)")
 }
diff --git a/internal/monitor/updatekuma.go b/internal/monitor/updatekuma.go
new file mode 100644
index 0000000..81bb163
--- /dev/null
+++ b/internal/monitor/updatekuma.go
@@ -0,0 +1,177 @@
+package monitor
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"time"
+
+	"github.com/google/go-querystring/query"
+	"golang.org/x/exp/slices"
+
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+// UptimeKuma provides basic support of Uptime Kuma.
+//
+//   - ExitStatus, Start, and Log will be no-op.
+//   - Success/Fail will be translated to status=up/down
+//   - Messages will be sent along with Success/Fail,
+//     but it seems Uptime Kuma will only display the first one.
+//   - ping will always be empty
+type UptimeKuma struct {
+	// The endpoint
+	BaseURL *url.URL
+
+	// Timeout for each ping
+	Timeout time.Duration
+}
+
+const (
+	// UptimeKumaDefaultTimeout is the default timeout for a UptimeKuma ping.
+	UptimeKumaDefaultTimeout = 10 * time.Second
+)
+
+func NewUptimeKuma(ppfmt pp.PP, rawURL string) (Monitor, bool) {
+	u, err := url.Parse(rawURL)
+	if err != nil {
+		ppfmt.Errorf(pp.EmojiUserError, "Failed to parse the Uptime Kuma URL (redacted)")
+		return nil, false
+	}
+
+	if !(u.IsAbs() && u.Opaque == "" && u.Host != "") {
+		ppfmt.Errorf(pp.EmojiUserError, `The Uptime Kuma URL (redacted) does not look like a valid URL`)
+		return nil, false
+	}
+
+	switch u.Scheme {
+	case "http":
+		ppfmt.Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS")
+
+	case "https":
+		// HTTPS is good!
+
+	default:
+		ppfmt.Errorf(pp.EmojiUserError, `The Uptime Kuma URL (redacted) does not look like a valid URL`)
+		return nil, false
+	}
+
+	// By default, the URL provided by Uptime Kuma has this:
+	//
+	//     https://some.host.name/api/push/GFWB6vsHMg?status=up&msg=Ok&ping=
+	//
+	// The following will check the query part
+	if u.RawQuery != "" {
+		q, err := url.ParseQuery(u.RawQuery)
+		if err != nil {
+			ppfmt.Errorf(pp.EmojiUserError, `The Uptime Kuma URL (redacted) does not look like a valid URL`)
+			return nil, false
+		}
+
+		for k, vs := range q {
+			switch {
+			case len(vs) == 0: // okay if it's empty
+			case k == "status" && slices.Equal(vs, []string{"up"}): // status=up
+			case k == "msg" && slices.Equal(vs, []string{"Ok"}): // msg=Ok
+			case k == "ping" && slices.Equal(vs, []string{""}): // ping=
+
+			default: // problematic case
+				ppfmt.Warningf(pp.EmojiUserError,
+					`The Uptime Kuma URL (redacted) contains an unexpected query %s=... and it will not be used`,
+					k)
+			}
+		}
+
+		// Clear all queries to obtain the base URL
+		u.RawQuery = ""
+	}
+
+	h := &UptimeKuma{
+		BaseURL: u,
+		Timeout: UptimeKumaDefaultTimeout,
+	}
+
+	return h, true
+}
+
+// Describe calls the callback with the service name "Uptime Kuma".
+func (h *UptimeKuma) Describe(callback func(service, params string)) {
+	callback("Uptime Kuma", "(URL redacted)")
+}
+
+// UptimeKumaResponse is for parsing the response from Uptime Kuma.
+type UptimeKumaResponse struct {
+	Ok  bool   `json:"ok"`
+	Msg string `json:"msg"`
+}
+
+// UptimeKumaRequest is for assembling the request to Uptime Kuma.
+type UptimeKumaRequest struct {
+	Status string `url:"status"`
+	Msg    string `url:"msg"`
+	Ping   string `url:"ping"`
+}
+
+func (h *UptimeKuma) ping(ctx context.Context, ppfmt pp.PP, param UptimeKumaRequest) bool {
+	ctx, cancel := context.WithTimeout(ctx, h.Timeout)
+	defer cancel()
+
+	url := *h.BaseURL
+	v, _ := query.Values(param)
+	url.RawQuery = v.Encode()
+
+	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url.String(), nil)
+	if err != nil {
+		ppfmt.Warningf(pp.EmojiImpossible, "Failed to prepare HTTP(S) request to Uptime Kuma: %v", err)
+		return false
+	}
+
+	resp, err := http.DefaultClient.Do(req)
+	if err != nil {
+		ppfmt.Warningf(pp.EmojiError, "Failed to send HTTP(S) request to Uptime Kuma: %v", err)
+		return false
+	}
+	defer resp.Body.Close()
+
+	var parsedResp UptimeKumaResponse
+	if err = json.NewDecoder(resp.Body).Decode(&parsedResp); err != nil {
+		ppfmt.Warningf(pp.EmojiError, "Failed to parse the response from Uptime Kuma: %v", err)
+		return false
+	}
+	if !parsedResp.Ok {
+		ppfmt.Warningf(pp.EmojiError, "Failed to ping Uptime Kuma: %q", parsedResp.Msg)
+		return false
+	}
+
+	ppfmt.Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma")
+	return true
+}
+
+// Success pings the server with status=up.
+func (h *UptimeKuma) Success(ctx context.Context, ppfmt pp.PP, message string) bool {
+	return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "up", Msg: message, Ping: ""})
+}
+
+// Start does nothing.
+func (h *UptimeKuma) Start(ctx context.Context, ppfmt pp.PP, message string) bool {
+	return true
+}
+
+// Failure pings the server with status=down.
+func (h *UptimeKuma) Failure(ctx context.Context, ppfmt pp.PP, message string) bool {
+	return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "down", Msg: message, Ping: ""})
+}
+
+// Log does nothing.
+func (h *UptimeKuma) Log(ctx context.Context, ppfmt pp.PP, message string) bool {
+	return true
+}
+
+// ExitStatus does nothing.
+func (h *UptimeKuma) ExitStatus(ctx context.Context, ppfmt pp.PP, code int, message string) bool {
+	if code != 0 {
+		return h.ping(ctx, ppfmt, UptimeKumaRequest{Status: "down", Msg: message, Ping: ""})
+	}
+	return true
+}


Test patch:
diff --git a/internal/config/config_print_test.go b/internal/config/config_print_test.go
index 703404d..41baf03 100644
--- a/internal/config/config_print_test.go
+++ b/internal/config/config_print_test.go
@@ -131,7 +131,7 @@ func TestPrintMaps(t *testing.T) {
 
 	m, ok := monitor.NewHealthchecks(mockPP, "https://user:pass@host/path")
 	require.True(t, ok)
-	c.Monitor = m
+	c.Monitors = []monitor.Monitor{m}
 
 	c.Print(mockPP)
 }
diff --git a/internal/config/env_base_test.go b/internal/config/env_base_test.go
index 0426e81..454eb91 100644
--- a/internal/config/env_base_test.go
+++ b/internal/config/env_base_test.go
@@ -13,7 +13,6 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/config"
 	"github.com/favonia/cloudflare-ddns/internal/cron"
 	"github.com/favonia/cloudflare-ddns/internal/mocks"
-	"github.com/favonia/cloudflare-ddns/internal/monitor"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 )
 
@@ -578,99 +577,3 @@ func urlMustParse(t *testing.T, u string) *url.URL {
 	require.Nil(t, err)
 	return url
 }
-
-//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global
-func TestReadHealthchecksURL(t *testing.T) {
-	key := keyPrefix + "HEALTHCHECKS"
-
-	type mon = monitor.Monitor
-
-	for name, tc := range map[string]struct {
-		set           bool
-		val           string
-		oldField      mon
-		newField      mon
-		ok            bool
-		prepareMockPP func(*mocks.MockPP)
-	}{
-		"unset": {
-			false, "", nil, nil, true, nil,
-		},
-		"empty": {
-			true, "", nil, nil, true, nil,
-		},
-		"example": {
-			true, "https://hi.org/1234",
-			nil,
-			&monitor.Healthchecks{
-				BaseURL: urlMustParse(t, "https://hi.org/1234"),
-				Timeout: monitor.HealthchecksDefaultTimeout,
-			},
-			true,
-			nil,
-		},
-		"password": {
-			true, "https://me:pass@hi.org/1234",
-			nil,
-			&monitor.Healthchecks{
-				BaseURL: urlMustParse(t, "https://me:pass@hi.org/1234"),
-				Timeout: monitor.HealthchecksDefaultTimeout,
-			},
-			true,
-			nil,
-		},
-		"fragment": {
-			true, "https://hi.org/1234#fragment",
-			nil,
-			&monitor.Healthchecks{
-				BaseURL: urlMustParse(t, "https://hi.org/1234#fragment"),
-				Timeout: monitor.HealthchecksDefaultTimeout,
-			},
-			true,
-			nil,
-		},
-		"query": {
-			true, "https://hi.org/1234?hello=123",
-			nil,
-			&monitor.Healthchecks{
-				BaseURL: urlMustParse(t, "https://hi.org/1234?hello=123"),
-				Timeout: monitor.HealthchecksDefaultTimeout,
-			},
-			true,
-			nil,
-		},
-		"illformed/not-url": {
-			true, "\001",
-			nil,
-			nil, false,
-			func(m *mocks.MockPP) {
-				m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse the Healthchecks URL (redacted)`)
-			},
-		},
-		"illformed/not-abs": {
-			true, "/1234?hello=123",
-			nil,
-			nil, false,
-			func(m *mocks.MockPP) {
-				gomock.InOrder(
-					m.EXPECT().Errorf(pp.EmojiUserError, `The Healthchecks URL (redacted) does not look like a valid URL`),
-					m.EXPECT().Errorf(pp.EmojiUserError, `A valid example is "https://hc-ping.com/01234567-0123-0123-0123-0123456789abc"`), //nolint:lll
-				)
-			},
-		},
-	} {
-		tc := tc
-		t.Run(name, func(t *testing.T) {
-			set(t, key, tc.set, tc.val)
-			field := tc.oldField
-			mockCtrl := gomock.NewController(t)
-			mockPP := mocks.NewMockPP(mockCtrl)
-			if tc.prepareMockPP != nil {
-				tc.prepareMockPP(mockPP)
-			}
-			ok := config.ReadHealthchecksURL(mockPP, key, &field)
-			require.Equal(t, tc.ok, ok)
-			require.Equal(t, tc.newField, field)
-		})
-	}
-}
diff --git a/internal/config/env_monitor_test.go b/internal/config/env_monitor_test.go
new file mode 100644
index 0000000..32f4f09
--- /dev/null
+++ b/internal/config/env_monitor_test.go
@@ -0,0 +1,208 @@
+package config_test
+
+import (
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/config"
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global
+func TestReadAndAppendHealthchecksURL(t *testing.T) {
+	key := keyPrefix + "HEALTHCHECKS"
+
+	type mon = monitor.Monitor
+
+	for name, tc := range map[string]struct {
+		set           bool
+		val           string
+		oldField      []mon
+		newField      []mon
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"unset": {
+			false, "", nil, nil, true, nil,
+		},
+		"empty": {
+			true, "", nil, nil, true, nil,
+		},
+		"example": {
+			true, "https://hi.org/1234",
+			nil,
+			[]mon{&monitor.Healthchecks{
+				BaseURL: urlMustParse(t, "https://hi.org/1234"),
+				Timeout: monitor.HealthchecksDefaultTimeout,
+			}},
+			true,
+			nil,
+		},
+		"password": {
+			true, "https://me:pass@hi.org/1234",
+			nil,
+			[]mon{&monitor.Healthchecks{
+				BaseURL: urlMustParse(t, "https://me:pass@hi.org/1234"),
+				Timeout: monitor.HealthchecksDefaultTimeout,
+			}},
+			true,
+			nil,
+		},
+		"fragment": {
+			true, "https://hi.org/1234#fragment",
+			nil,
+			[]mon{&monitor.Healthchecks{
+				BaseURL: urlMustParse(t, "https://hi.org/1234#fragment"),
+				Timeout: monitor.HealthchecksDefaultTimeout,
+			}},
+			true,
+			nil,
+		},
+		"query": {
+			true, "https://hi.org/1234?hello=123",
+			nil,
+			nil,
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Errorf(pp.EmojiUserError, `The Healthchecks URL (redacted) does not look like a valid URL`),
+					m.EXPECT().Errorf(pp.EmojiUserError, `A valid example is "https://hc-ping.com/01234567-0123-0123-0123-0123456789abc"`), //nolint:lll
+				)
+			},
+		},
+		"illformed/not-url": {
+			true, "\001",
+			nil,
+			nil, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse the Healthchecks URL (redacted)`)
+			},
+		},
+		"illformed/not-abs": {
+			true, "/1234?hello=123",
+			nil,
+			nil, false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Errorf(pp.EmojiUserError, `The Healthchecks URL (redacted) does not look like a valid URL`),
+					m.EXPECT().Errorf(pp.EmojiUserError, `A valid example is "https://hc-ping.com/01234567-0123-0123-0123-0123456789abc"`), //nolint:lll
+				)
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			set(t, key, tc.set, tc.val)
+			field := tc.oldField
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadAndAppendHealthchecksURL(mockPP, key, &field)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.newField, field)
+		})
+	}
+}
+
+//nolint:paralleltest,funlen // paralleltest should not be used because environment vars are global
+func TestReadAndAppendUptimeKumaURL(t *testing.T) {
+	key := keyPrefix + "UPTIMEKUMA"
+
+	type mon = monitor.Monitor
+
+	for name, tc := range map[string]struct {
+		set           bool
+		val           string
+		oldField      []mon
+		newField      []mon
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"unset": {
+			false, "", nil, nil, true, nil,
+		},
+		"empty": {
+			true, "", nil, nil, true, nil,
+		},
+		"example": {
+			true, "https://hi.org/1234",
+			nil,
+			[]mon{&monitor.UptimeKuma{
+				BaseURL: urlMustParse(t, "https://hi.org/1234"),
+				Timeout: monitor.UptimeKumaDefaultTimeout,
+			}},
+			true,
+			nil,
+		},
+		"password": {
+			true, "https://me:pass@hi.org/1234",
+			nil,
+			[]mon{&monitor.UptimeKuma{
+				BaseURL: urlMustParse(t, "https://me:pass@hi.org/1234"),
+				Timeout: monitor.UptimeKumaDefaultTimeout,
+			}},
+			true,
+			nil,
+		},
+		"fragment": {
+			true, "https://hi.org/1234#fragment",
+			nil,
+			[]mon{&monitor.UptimeKuma{
+				BaseURL: urlMustParse(t, "https://hi.org/1234#fragment"),
+				Timeout: monitor.UptimeKumaDefaultTimeout,
+			}},
+			true,
+			nil,
+		},
+		"query": {
+			true, "https://hi.org/1234?hello=123",
+			nil,
+			[]mon{&monitor.UptimeKuma{
+				BaseURL: urlMustParse(t, "https://hi.org/1234"),
+				Timeout: monitor.UptimeKumaDefaultTimeout,
+			}},
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserError,
+					`The Uptime Kuma URL (redacted) contains an unexpected query %s=... and it will not be used`,
+					"hello")
+			},
+		},
+		"illformed/not-url": {
+			true, "\001",
+			nil,
+			nil, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, `Failed to parse the Uptime Kuma URL (redacted)`)
+			},
+		},
+		"illformed/not-abs": {
+			true, "/1234?hello=123",
+			nil,
+			nil, false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, `The Uptime Kuma URL (redacted) does not look like a valid URL`)
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			set(t, key, tc.set, tc.val)
+			field := tc.oldField
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			ok := config.ReadAndAppendUptimeKumaURL(mockPP, key, &field)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.newField, field)
+		})
+	}
+}
diff --git a/internal/monitor/composite_test.go b/internal/monitor/composite_test.go
index 64a848d..736e563 100644
--- a/internal/monitor/composite_test.go
+++ b/internal/monitor/composite_test.go
@@ -10,7 +10,7 @@ import (
 	"github.com/favonia/cloudflare-ddns/internal/monitor"
 )
 
-func TestMonitorsDescribe(t *testing.T) {
+func TestDescribeAll(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -23,10 +23,10 @@ func TestMonitorsDescribe(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.Monitors(ms).Describe(func(service, params string) {})
+	monitor.DescribeAll(func(service, params string) {}, ms)
 }
 
-func TestMonitorsSuccess(t *testing.T) {
+func TestSuccessAll(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -42,10 +42,10 @@ func TestMonitorsSuccess(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.Monitors(ms).Success(context.Background(), mockPP, message)
+	monitor.SuccessAll(context.Background(), mockPP, message, ms)
 }
 
-func TestMonitorsStart(t *testing.T) {
+func TestStartAll(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -61,10 +61,10 @@ func TestMonitorsStart(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.Monitors(ms).Start(context.Background(), mockPP, message)
+	monitor.StartAll(context.Background(), mockPP, message, ms)
 }
 
-func TestMonitorsFailure(t *testing.T) {
+func TestFailureAll(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -80,10 +80,10 @@ func TestMonitorsFailure(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.Monitors(ms).Failure(context.Background(), mockPP, message)
+	monitor.FailureAll(context.Background(), mockPP, message, ms)
 }
 
-func TestMonitorsLog(t *testing.T) {
+func TestLogAll(t *testing.T) {
 	t.Parallel()
 
 	var ms []monitor.Monitor
@@ -99,7 +99,7 @@ func TestMonitorsLog(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.Monitors(ms).Log(context.Background(), mockPP, message)
+	monitor.LogAll(context.Background(), mockPP, message, ms)
 }
 
 func TestMonitorsExitStatus(t *testing.T) {
@@ -118,5 +118,5 @@ func TestMonitorsExitStatus(t *testing.T) {
 		ms = append(ms, m)
 	}
 
-	monitor.Monitors(ms).ExitStatus(context.Background(), mockPP, 42, message)
+	monitor.ExitStatusAll(context.Background(), mockPP, 42, message, ms)
 }
diff --git a/internal/monitor/healthchecks_test.go b/internal/monitor/healthchecks_test.go
index 4f8657f..57dc7ee 100644
--- a/internal/monitor/healthchecks_test.go
+++ b/internal/monitor/healthchecks_test.go
@@ -19,21 +19,21 @@ import (
 func TestNewHealthchecks(t *testing.T) {
 	t.Parallel()
 
-	rawURL := "https://user:pass@host/path"
-	parsedURL, err := url.Parse(rawURL)
+	rawBaseURL := "https://user:pass@host/path"
+	parsedBaseURL, err := url.Parse(rawBaseURL)
 	require.NoError(t, err)
 
 	mockCtrl := gomock.NewController(t)
 	mockPP := mocks.NewMockPP(mockCtrl)
-	m, ok := monitor.NewHealthchecks(mockPP, rawURL)
+	m, ok := monitor.NewHealthchecks(mockPP, rawBaseURL)
 	require.Equal(t, &monitor.Healthchecks{
-		BaseURL: parsedURL,
+		BaseURL: parsedBaseURL,
 		Timeout: monitor.HealthchecksDefaultTimeout,
 	}, m)
 	require.True(t, ok)
 }
 
-func TestNewHealthchecksFail1(t *testing.T) {
+func TestHealthchecksNewHealthchecksFail1(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -46,7 +46,7 @@ func TestNewHealthchecksFail1(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestNewHealthchecksFail2(t *testing.T) {
+func TestHealthchecksNewHealthchecksFail2(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -59,7 +59,7 @@ func TestNewHealthchecksFail2(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestNewHealthchecksFail3(t *testing.T) {
+func TestHealthchecksNewHealthchecksFail3(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -69,7 +69,7 @@ func TestNewHealthchecksFail3(t *testing.T) {
 	require.False(t, ok)
 }
 
-func TestDescripbe(t *testing.T) {
+func TestHealthchecksDescripbe(t *testing.T) {
 	t.Parallel()
 
 	mockCtrl := gomock.NewController(t)
@@ -82,7 +82,7 @@ func TestDescripbe(t *testing.T) {
 }
 
 //nolint:funlen
-func TestEndPoints(t *testing.T) {
+func TestHealthchecksEndPoints(t *testing.T) {
 	t.Parallel()
 
 	type action int
diff --git a/internal/monitor/updatekuma_test.go b/internal/monitor/updatekuma_test.go
new file mode 100644
index 0000000..ef0680e
--- /dev/null
+++ b/internal/monitor/updatekuma_test.go
@@ -0,0 +1,306 @@
+package monitor_test
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/golang/mock/gomock"
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/mocks"
+	"github.com/favonia/cloudflare-ddns/internal/monitor"
+	"github.com/favonia/cloudflare-ddns/internal/pp"
+)
+
+func TestNewUptimeKuma(t *testing.T) {
+	t.Parallel()
+
+	rawBaseURL := "https://user:pass@host/path"
+	parsedBaseURL, err := url.Parse(rawBaseURL)
+	require.NoError(t, err)
+
+	for name, tc := range map[string]struct {
+		input         string
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"bare": {"https://user:pass@host/path", true, nil},
+		"full": {"https://user:pass@host/path?status=up&msg=Ok&ping=", true, nil},
+		"ill-formed-query": {
+			"https://user:pass@host/path?status=up;msg=Ok;ping=", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "The Uptime Kuma URL (redacted) does not look like a valid URL")
+			},
+		},
+		"ftp": {
+			"ftp://user:pass@host/", false,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Errorf(pp.EmojiUserError, "The Uptime Kuma URL (redacted) does not look like a valid URL")
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+			m, ok := monitor.NewUptimeKuma(mockPP, tc.input)
+			require.Equal(t, tc.ok, ok)
+			if ok {
+				require.Equal(t, &monitor.UptimeKuma{
+					BaseURL: parsedBaseURL,
+					Timeout: monitor.UptimeKumaDefaultTimeout,
+				}, m)
+			} else {
+				require.Nil(t, m)
+			}
+		})
+	}
+}
+
+func TestUptimeKumaDescripbe(t *testing.T) {
+	t.Parallel()
+
+	mockCtrl := gomock.NewController(t)
+	mockPP := mocks.NewMockPP(mockCtrl)
+	m, ok := monitor.NewUptimeKuma(mockPP, "https://user:pass@host/path")
+	require.True(t, ok)
+	m.Describe(func(service, params string) {
+		require.Equal(t, "Uptime Kuma", service)
+	})
+}
+
+//nolint:funlen
+func TestUptimeKumaEndPoints(t *testing.T) {
+	t.Parallel()
+
+	type action int
+	const (
+		ActionOk action = iota
+		ActionNotOk
+		ActionGarbage
+		ActionAbort
+		ActionFail
+	)
+
+	for name, tc := range map[string]struct {
+		endpoint      func(pp.PP, monitor.Monitor) bool
+		url           string
+		status        string
+		msg           string
+		ping          string
+		actions       []action
+		defaultAction action
+		pinged        bool
+		ok            bool
+		prepareMockPP func(*mocks.MockPP)
+	}{
+		"success": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Success(context.Background(), ppfmt, "hello")
+			},
+			"/", "up", "hello", "",
+			[]action{ActionOk},
+			ActionAbort, true,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma"),
+				)
+			},
+		},
+		"success/not-ok": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Success(context.Background(), ppfmt, "aloha")
+			},
+			"/", "up", "aloha", "",
+			[]action{ActionNotOk},
+			ActionAbort, false,
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Warningf(pp.EmojiError, "Failed to ping Uptime Kuma: %q", "bad"),
+				)
+			},
+		},
+		"success/garbage-response": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Success(context.Background(), ppfmt, "aloha")
+			},
+			"/", "up", "aloha", "",
+			[]action{ActionGarbage},
+			ActionAbort, false,
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Warningf(pp.EmojiError, "Failed to parse the response from Uptime Kuma: %v", gomock.Any()),
+				)
+			},
+		},
+		"success/abort/all": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Success(context.Background(), ppfmt, "stop now")
+			},
+			"/", "up", "stop now", "",
+			nil, ActionAbort, false,
+			false,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Warningf(pp.EmojiError, "Failed to send HTTP(S) request to Uptime Kuma: %v", gomock.Any()),
+				)
+			},
+		},
+		"start": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Start(context.Background(), ppfmt, "starting now!")
+			},
+			"/", "", "", "",
+			[]action{},
+			ActionAbort, false,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS")
+			},
+		},
+		"failure": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Failure(context.Background(), ppfmt, "something's wrong")
+			},
+			"/", "down", "something's wrong", "",
+			[]action{ActionOk},
+			ActionAbort, true,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma"),
+				)
+			},
+		},
+		"log": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.Log(context.Background(), ppfmt, "message")
+			},
+			"/", "", "", "",
+			[]action{},
+			ActionAbort, false,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS")
+			},
+		},
+		"exitstatus/0": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.ExitStatus(context.Background(), ppfmt, 0, "bye!")
+			},
+			"/", "", "", "",
+			[]action{},
+			ActionAbort, false,
+			true,
+			func(m *mocks.MockPP) {
+				m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS")
+			},
+		},
+		"exitstatus/1": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.ExitStatus(context.Background(), ppfmt, 1, "did exit(1)")
+			},
+			"/", "down", "did exit(1)", "",
+			[]action{ActionOk},
+			ActionAbort, true,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma"),
+				)
+			},
+		},
+		"exitstatus/-1": {
+			func(ppfmt pp.PP, m monitor.Monitor) bool {
+				return m.ExitStatus(context.Background(), ppfmt, -1, "feeling negative")
+			},
+			"/", "down", "feeling negative", "",
+			[]action{ActionOk},
+			ActionAbort, true,
+			true,
+			func(m *mocks.MockPP) {
+				gomock.InOrder(
+					m.EXPECT().Warningf(pp.EmojiUserWarning, "The Uptime Kuma URL (redacted) uses HTTP; please consider using HTTPS"),
+					m.EXPECT().Infof(pp.EmojiNotification, "Successfully pinged Uptime Kuma"),
+				)
+			},
+		},
+	} {
+		tc := tc
+		t.Run(name, func(t *testing.T) {
+			t.Parallel()
+			mockCtrl := gomock.NewController(t)
+			mockPP := mocks.NewMockPP(mockCtrl)
+			if tc.prepareMockPP != nil {
+				tc.prepareMockPP(mockPP)
+			}
+
+			visited := 0
+			pinged := false
+			server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+				require.Equal(t, http.MethodGet, r.Method)
+				require.Equal(t, tc.url, r.URL.EscapedPath())
+
+				q, err := url.ParseQuery(r.URL.RawQuery)
+				require.Nil(t, err)
+				require.Equal(t, url.Values{
+					"status": {tc.status},
+					"msg":    {tc.msg},
+					"ping":   {tc.ping},
+				}, q)
+
+				reqBody, err := io.ReadAll(r.Body)
+				require.Nil(t, err)
+				require.Empty(t, string(reqBody))
+
+				visited++
+				action := tc.defaultAction
+				if visited <= len(tc.actions) {
+					action = tc.actions[visited-1]
+				}
+				switch action {
+				case ActionOk:
+					pinged = true
+					_, err := io.WriteString(w, `{"ok":true}`)
+					require.NoError(t, err)
+				case ActionNotOk:
+					_, err := io.WriteString(w, `{"ok":false,"msg":"bad"}`)
+					require.NoError(t, err)
+				case ActionGarbage:
+					_, err := io.WriteString(w, `This is [ { not a valid JSON`)
+					require.NoError(t, err)
+				case ActionAbort:
+					panic(http.ErrAbortHandler)
+				case ActionFail:
+					require.FailNow(t, "failing the test")
+				default:
+					require.FailNow(t, "failing the test")
+				}
+			}))
+
+			m, ok := monitor.NewUptimeKuma(mockPP, server.URL)
+			require.True(t, ok)
+			ok = tc.endpoint(mockPP, m)
+			require.Equal(t, tc.ok, ok)
+			require.Equal(t, tc.pinged, pinged)
+		})
+	}
+}