Repository: favonia/cloudflare-ddns (go)

Issue:
Add a custom provider
For my use case being able to configure a custom provider would be useful. It would basically be a http provider like ipify, but with the ability to use an env variable to configure any url. 

Pull request description:
feat(provider): implement custom URL providers
This introduces a new provider `url:URL`, for example `url:https://api6.ipify.org`. Closes #546.

Patch:
diff --git a/README.markdown b/README.markdown
index 9b6d799..d3a9316 100644
--- a/README.markdown
+++ b/README.markdown
@@ -232,6 +232,8 @@ _(Click to expand the following items.)_
 >   Get the address via local network interfaces and update DNS records accordingly. When multiple local network interfaces or in general multiple IP addresses are present, the updater will use the address that would have been used for outbound UDP connections to Cloudflare servers. ⚠️ You need access to the host network (such as `network_mode: host` in Docker Compose) for this policy, for otherwise the updater will detect the addresses inside the [bridge network in Docker](https://docs.docker.com/network/bridge/) instead of those in the host network.
 > - `none`\
 >   Stop the DNS updating completely. Existing DNS records will not be removed.
+> - `url:URL` (🧪 experimental)\
+>   Fetch the content at a URL via the HTTP(S) protocol as the IP address. The provider format is `url:` followed by the URL. For example, `IP4_PROVIDER=url:https://api4.ipify.org` will fetch the IPv4 addresses from <https://api4.ipify.org>, a server maintained by [ipify](https://www.ipify.org). ⚠️ Currently, the updater does not force the HTTP(S) connections to use IPv4 or IPv6 when retrieving the content at the URL, and thus the service must restrict its access to the correct network (IPv4 or IPv6) or return the correct address even when the wrong network is used. The reason is that there is no elegant way to force IPv4 or IPv6 using the Go standard library; please [open a GitHub issue](https://github.com/favonia/cloudflare-ddns/issues/new) if you have a use case.
 >
 > The option `IP4_PROVIDER` is governing IPv4 addresses and `A`-type records, while the option `IP6_PROVIDER` is governing IPv6 addresses and `AAAA`-type records. The two options act independently of each other; that is, you can specify different address providers for IPv4 and IPv6.
 >
@@ -325,11 +327,11 @@ _(Click to expand the following items.)_
 <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)       |
+| 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)       |
 
 > 🩺 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/internal/config/env_provider.go b/internal/config/env_provider.go
index d7661fc..d952841 100644
--- a/internal/config/env_provider.go
+++ b/internal/config/env_provider.go
@@ -1,6 +1,8 @@
 package config
 
 import (
+	"strings"
+
 	"github.com/favonia/cloudflare-ddns/internal/ipnet"
 	"github.com/favonia/cloudflare-ddns/internal/pp"
 	"github.com/favonia/cloudflare-ddns/internal/provider"
@@ -109,6 +111,11 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid
 			*field = nil
 			return true
 		default:
+			if strings.HasPrefix(val, "url:") {
+				url := strings.TrimSpace(strings.TrimPrefix(val, "url:"))
+				*field = provider.NewCustom(url)
+				return true
+			}
 			ppfmt.Errorf(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val)
 			return false
 		}
diff --git a/internal/provider/custom.go b/internal/provider/custom.go
new file mode 100644
index 0000000..8f5ac71
--- /dev/null
+++ b/internal/provider/custom.go
@@ -0,0 +1,18 @@
+package provider
+
+import (
+	"github.com/favonia/cloudflare-ddns/internal/ipnet"
+	"github.com/favonia/cloudflare-ddns/internal/provider/protocol"
+)
+
+// NewCustom creates a HTTP provider.
+func NewCustom(url string) Provider {
+	return &protocol.HTTP{
+		ProviderName:     "custom",
+		Is1111UsedForIP4: false,
+		URL: map[ipnet.Type]protocol.Switch{
+			ipnet.IP4: protocol.Constant(url),
+			ipnet.IP6: protocol.Constant(url),
+		},
+	}
+}


Test patch:
diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go
index e35f204..3e46ac1 100644
--- a/internal/config/env_provider_test.go
+++ b/internal/config/env_provider_test.go
@@ -19,11 +19,12 @@ func TestReadProvider(t *testing.T) {
 	keyDeprecated := keyPrefix + "DEPRECATED"
 
 	var (
-		none  provider.Provider
-		doh   = provider.NewCloudflareDOH()
-		trace = provider.NewCloudflareTrace()
-		local = provider.NewLocal()
-		ipify = provider.NewIpify()
+		none   provider.Provider
+		doh    = provider.NewCloudflareDOH()
+		trace  = provider.NewCloudflareTrace()
+		local  = provider.NewLocal()
+		ipify  = provider.NewIpify()
+		custom = provider.NewCustom("https://url.io")
 	)
 
 	for name, tc := range map[string]struct {
@@ -153,6 +154,7 @@ func TestReadProvider(t *testing.T) {
 		"cloudflare.doh":   {true, "    \tcloudflare.doh   ", false, "", none, doh, true, nil},
 		"none":             {true, "   none   ", false, "", trace, none, true, nil},
 		"local":            {true, "   local   ", false, "", trace, local, true, nil},
+		"custom":           {true, "   url:https://url.io   ", false, "", trace, custom, true, nil},
 		"ipify": {
 			true, "     ipify  ", false, "", trace, ipify, true,
 			func(m *mocks.MockPP) {
diff --git a/internal/provider/custom_test.go b/internal/provider/custom_test.go
new file mode 100644
index 0000000..8c0bf5e
--- /dev/null
+++ b/internal/provider/custom_test.go
@@ -0,0 +1,15 @@
+package provider_test
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/require"
+
+	"github.com/favonia/cloudflare-ddns/internal/provider"
+)
+
+func TestCustomName(t *testing.T) {
+	t.Parallel()
+
+	require.Equal(t, "custom", provider.Name(provider.NewCustom("")))
+}