Repository: favonia/cloudflare-ddns (go)

Issue:
Support Uptime Kuma
This service works much better for me than previous solutions I was using.  Internally to my setup I'm using uptime kuma for monitoring and service health, looks like the healthchecks here are coupled directly to format of healthchecks.io implementation which isn't compatible (at least as far as I can tell) with uptime kuma push monitors.  Example URL of uptime kuma:

http://my_internal_ip:3001/api/push/rW5bnO05uv?status=up&msg=OK&ping=
or
http://my_internal_ip:3001/api/push/rW5bnO05uv

Both work, status, msg and ping are optional.  Would really like the heartbeat checks here to be able to ping uptime kuma as well.

Pull request description:
feat: add support of Uptime Kuma
This reverts commit c267f9426799bdbffb4a1c8f3e29b9e1f4927d06. Close #599.

Patch:
diff --git a/README.markdown b/README.markdown
index edc6452..ad08dee 100644
--- a/README.markdown
+++ b/README.markdown
@@ -32,7 +32,7 @@ By default, public IP addresses are obtained via [Cloudflare debugging page](htt
 
 - 🛑 Superuser privileges are immediately dropped, minimizing the impact of undiscovered bugs.
 - 🛡️ The updater uses only HTTPS or [DNS over HTTPS](https://en.wikipedia.org/wiki/DNS_over_HTTPS) to detect IP addresses; see the [Security Model](docs/DESIGN.markdown#network-security-threat-model).
-- 🩺 The updater supports [Healthchecks](https://healthchecks.io), which can notify you when the updating fails.
+- 🩺 The updater can notify you via [Healthchecks](https://healthchecks.io) and [Uptime Kuma](https://uptime.kuma.pet) when the updating fails.
 - <details><summary>📚 The updater uses only established open-source Go libraries <em>(click to expand)</em></summary>
 
   - [cap](https://sites.google.com/site/fullycapable):\
@@ -327,13 +327,14 @@ _(Click to expand the following items.)_
 </details>
 
 <details>
-<summary>👁️ Logging and Healthchecks</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)       |
+<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)       |
+| 🧪 `UPTIMEKUMA` (experimental) | 🧪 Uptime Kuma’s Push URLs, such as `https://<host>/push/<id>`. For convenience, you 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 works regardless whether the POST-only mode is enabled.
 
diff --git a/go.mod b/go.mod
index 26a05fe..5d42558 100644
--- a/go.mod
+++ b/go.mod
@@ -4,6 +4,7 @@ go 1.21
 
 require (
 	github.com/cloudflare/cloudflare-go v0.78.0
+	github.com/google/go-querystring v1.1.0
 	github.com/hashicorp/go-retryablehttp v0.7.4
 	github.com/jellydator/ttlcache/v3 v3.1.0
 	github.com/robfig/cron/v3 v3.0.1
@@ -16,7 +17,6 @@ require (
 require (
 	github.com/davecgh/go-spew v1.1.1 // indirect
 	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/pmezard/go-difflib v1.0.0 // indirect
 	golang.org/x/sync v0.3.0 // indirect
diff --git a/internal/config/config_read.go b/internal/config/config_read.go
index befbb74..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) ||
-		!ReadAndAppendHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) {
+		!ReadAndAppendHealthchecksURL(ppfmt, "HEALTHCHECKS", &c.Monitors) ||
+		!ReadAndAppendUptimeKumaURL(ppfmt, "UPTIMEKUMA", &c.Monitors) {
 		return false
 	}
 
diff --git a/internal/config/env_monitor.go b/internal/config/env_monitor.go
index b34e2fb..d8643e7 100644
--- a/internal/config/env_monitor.go
+++ b/internal/config/env_monitor.go
@@ -22,3 +22,21 @@ func ReadAndAppendHealthchecksURL(ppfmt pp.PP, key string, field *[]monitor.Moni
 	*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/updatekuma.go b/internal/monitor/updatekuma.go
new file mode 100644
index 0000000..08fb39f
--- /dev/null
+++ b/internal/monitor/updatekuma.go
@@ -0,0 +1,177 @@
+package monitor
+
+import (
+	"context"
+	"encoding/json"
+	"net/http"
+	"net/url"
+	"slices"
+	"time"
+
+	"github.com/google/go-querystring/query"
+
+	"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
+)
+
+// NewUptimeKuma creates a new UptimeKuma monitor.
+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 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/env_monitor_test.go b/internal/config/env_monitor_test.go
index a6713ea..2076c3a 100644
--- a/internal/config/env_monitor_test.go
+++ b/internal/config/env_monitor_test.go
@@ -109,3 +109,100 @@ func TestReadAndAppendHealthchecksURL(t *testing.T) {
 		})
 	}
 }
+
+//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/updatekuma_test.go b/internal/monitor/updatekuma_test.go
new file mode 100644
index 0000000..9f74713
--- /dev/null
+++ b/internal/monitor/updatekuma_test.go
@@ -0,0 +1,312 @@
+package monitor_test
+
+import (
+	"context"
+	"io"
+	"net/http"
+	"net/http/httptest"
+	"net/url"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"go.uber.org/mock/gomock"
+
+	"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},
+		"unexpected": {
+			"https://user:pass@host/path?random=", 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", "random") //nolint:lll
+			},
+		},
+		"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)
+		})
+	}
+}