diff --git a/internal/config/env_provider.go b/internal/config/env_provider.go index 044e3170..b9fd5a3c 100644 --- a/internal/config/env_provider.go +++ b/internal/config/env_provider.go @@ -123,8 +123,7 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid return false } ppfmt.Hintf(pp.HintExperimentalLocalWithInterface, - `You are using the experimental provider "local.iface:%s" added in version 1.15.0`, - parts[1]) + `You are using the experimental "local.iface" provider added in version 1.15.0`) *field = provider.NewLocalWithInterface(parts[1]) return true case len(parts) == 2 && parts[0] == "url": @@ -136,6 +135,21 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid case len(parts) == 1 && parts[0] == "none": *field = nil return true + case len(parts) == 2 && parts[0] == "debug.const": + ppfmt.Hintf(pp.HintDebugConstProvider, `You are using the undocumented "debug.const" provider`) + if parts[1] == "" { + ppfmt.Noticef( + pp.EmojiUserError, + `%s=debug.const: must be followed by an IP address`, + key, + ) + return false + } + p, ok := provider.NewDebugConst(ppfmt, parts[1]) + if ok { + *field = p + } + return ok default: ppfmt.Noticef(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val) return false diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go index 7f113989..c30928eb 100644 --- a/internal/config/env_provider_test.go +++ b/internal/config/env_provider_test.go @@ -27,6 +27,7 @@ func TestReadProvider(t *testing.T) { localLoopback = provider.NewLocalWithInterface("lo") ipify = provider.NewIpify() custom = provider.MustNewCustomURL("https://url.io") + debugConst = provider.MustNewDebugConst("1.1.1.1") ) for name, tc := range map[string]struct { @@ -118,7 +119,7 @@ func TestReadProvider(t *testing.T) { "local.iface:lo": { true, " local.iface : lo ", false, "", trace, localLoopback, true, func(m *mocks.MockPP) { - m.EXPECT().Hintf(pp.HintExperimentalLocalWithInterface, `You are using the experimental provider "local.iface:%s" added in version 1.15.0`, "lo") + m.EXPECT().Hintf(pp.HintExperimentalLocalWithInterface, `You are using the experimental "local.iface" provider added in version 1.15.0`) }, }, "local.iface:": { @@ -140,6 +141,21 @@ func TestReadProvider(t *testing.T) { m.EXPECT().Noticef(pp.EmojiUserError, "%s (%q) is not a valid provider", key, "something-else") }, }, + "debug.const:1.1.1.1": { + true, " debug.const : 1.1.1.1 ", false, "", trace, debugConst, true, + func(m *mocks.MockPP) { + m.EXPECT().Hintf(pp.HintDebugConstProvider, `You are using the undocumented "debug.const" provider`) + }, + }, + "debug.const": { + true, " debug.const: ", false, "", trace, trace, false, + func(m *mocks.MockPP) { + gomock.InOrder( + m.EXPECT().Hintf(pp.HintDebugConstProvider, `You are using the undocumented "debug.const" provider`), + m.EXPECT().Noticef(pp.EmojiUserError, `%s=debug.const: must be followed by an IP address`, key), + ) + }, + }, } { t.Run(name, func(t *testing.T) { set(t, key, tc.set, tc.val) diff --git a/internal/pp/hint.go b/internal/pp/hint.go index cf9368cc..7ccc367d 100644 --- a/internal/pp/hint.go +++ b/internal/pp/hint.go @@ -20,4 +20,5 @@ const ( HintExperimentalShoutrrr // New feature introduced in 1.12.0 on 2024/6/28 HintExperimentalWAF // New feature introduced in 1.14.0 on 2024/8/25 HintExperimentalLocalWithInterface // New feature introduced in 1.15.0 + HintDebugConstProvider // Undocumented feature ) diff --git a/internal/provider/const.go b/internal/provider/const.go new file mode 100644 index 00000000..0dc90f1c --- /dev/null +++ b/internal/provider/const.go @@ -0,0 +1,33 @@ +package provider + +import ( + "net/netip" + "strings" + + "github.com/favonia/cloudflare-ddns/internal/pp" + "github.com/favonia/cloudflare-ddns/internal/provider/protocol" +) + +// NewDebugConst creates a [protocol.Const] provider. +func NewDebugConst(ppfmt pp.PP, raw string) (Provider, bool) { + ip, err := netip.ParseAddr(raw) + if err != nil { + ppfmt.Noticef(pp.EmojiUserError, `Failed to parse the IP address %q following "const:"`, raw) + return nil, false + } + + return protocol.Const{ + ProviderName: "debug.const:" + ip.String(), + IP: ip, + }, true +} + +// MustNewDebugConst creates a [protocol.Const] provider and panics if it fails. +func MustNewDebugConst(raw string) Provider { + var buf strings.Builder + p, ok := NewDebugConst(pp.NewDefault(&buf), raw) + if !ok { + panic(buf.String()) + } + return p +} diff --git a/internal/provider/const_test.go b/internal/provider/const_test.go new file mode 100644 index 00000000..763709e5 --- /dev/null +++ b/internal/provider/const_test.go @@ -0,0 +1,39 @@ +package provider_test + +import ( + "testing" + + "github.com/stretchr/testify/require" + + "github.com/favonia/cloudflare-ddns/internal/provider" +) + +func TestDebugConstName(t *testing.T) { + t.Parallel() + + require.Equal(t, "debug.const:1.1.1.1", provider.Name(provider.MustNewDebugConst("1.1.1.1"))) +} + +func TestMustDebugConst(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + input string + ok bool + }{ + {"1.1.1.1", true}, + {"1::1%1", true}, + {"", false}, + {"blah", false}, + } { + t.Run(tc.input, func(t *testing.T) { + t.Parallel() + + if tc.ok { + require.NotPanics(t, func() { provider.MustNewDebugConst(tc.input) }) + } else { + require.Panics(t, func() { provider.MustNewDebugConst(tc.input) }) + } + }) + } +} diff --git a/internal/provider/protocol/const.go b/internal/provider/protocol/const.go new file mode 100644 index 00000000..295f3ba2 --- /dev/null +++ b/internal/provider/protocol/const.go @@ -0,0 +1,29 @@ +package protocol + +import ( + "context" + "net/netip" + + "github.com/favonia/cloudflare-ddns/internal/ipnet" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +// Const returns the same IP. +type Const struct { + // Name of the detection protocol. + ProviderName string + + // The IP. + IP netip.Addr +} + +// Name of the detection protocol. +func (p Const) Name() string { + return p.ProviderName +} + +// GetIP returns the IP. +func (p Const) GetIP(_ context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) { + normalizedIP, ok := ipNet.NormalizeDetectedIP(ppfmt, p.IP) + return normalizedIP, MethodPrimary, ok +} diff --git a/internal/provider/protocol/const_test.go b/internal/provider/protocol/const_test.go new file mode 100644 index 00000000..048f4914 --- /dev/null +++ b/internal/provider/protocol/const_test.go @@ -0,0 +1,85 @@ +// vim: nowrap +//go:build linux + +package protocol_test + +import ( + "context" + "net/netip" + "testing" + + "github.com/stretchr/testify/require" + "go.uber.org/mock/gomock" + + "github.com/favonia/cloudflare-ddns/internal/ipnet" + "github.com/favonia/cloudflare-ddns/internal/mocks" + "github.com/favonia/cloudflare-ddns/internal/pp" + "github.com/favonia/cloudflare-ddns/internal/provider/protocol" +) + +func TestConstName(t *testing.T) { + t.Parallel() + + p := &protocol.Const{ + ProviderName: "very secret name", + IP: netip.Addr{}, + } + + require.Equal(t, "very secret name", p.Name()) +} + +func TestConstGetIP(t *testing.T) { + t.Parallel() + + var invalidIP netip.Addr + + for name, tc := range map[string]struct { + savedIP netip.Addr + ipNet ipnet.Type + ok bool + expected netip.Addr + prepareMockPP func(*mocks.MockPP) + }{ + "valid/4": { + netip.MustParseAddr("1.1.1.1"), ipnet.IP4, + true, netip.MustParseAddr("1.1.1.1"), nil, + }, + "valid/6": { + netip.MustParseAddr("1::1%1"), ipnet.IP6, + true, netip.MustParseAddr("1::1%1"), nil, + }, + "error/invalid": { + invalidIP, ipnet.IP6, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, "Detected IP address is not valid; this should not happen and please report it at %s", pp.IssueReportingURL) + }, + }, + "error/6-as-4": { + netip.MustParseAddr("1::1%1"), ipnet.IP4, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiError, "Detected IP address %s is not a valid IPv4 address", "1::1%1") + }, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + + provider := &protocol.Const{ + ProviderName: "", + IP: tc.savedIP, + } + ip, method, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet) + require.Equal(t, tc.ok, ok) + require.NotEqual(t, protocol.MethodAlternative, method) + require.Equal(t, tc.expected, ip) + }) + } +}