Skip to content

Commit

Permalink
feat: add support of Uptime Kuma (#600)
Browse files Browse the repository at this point in the history
This reverts the previous removal caused by undertesting.
  • Loading branch information
favonia committed Oct 13, 2023
1 parent a5a4504 commit c68eeeb
Show file tree
Hide file tree
Showing 7 changed files with 616 additions and 10 deletions.
17 changes: 9 additions & 8 deletions README.markdown
Original file line number Diff line number Diff line change
Expand Up @@ -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):\
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
3 changes: 2 additions & 1 deletion internal/config/config_read.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
18 changes: 18 additions & 0 deletions internal/config/env_monitor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
97 changes: 97 additions & 0 deletions internal/config/env_monitor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
}
}
177 changes: 177 additions & 0 deletions internal/monitor/updatekuma.go
Original file line number Diff line number Diff line change
@@ -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
}
Loading

0 comments on commit c68eeeb

Please sign in to comment.