From a962e6928d0401ca8c957952cfcdee24158e6653 Mon Sep 17 00:00:00 2001 From: favonia Date: Sat, 21 Sep 2024 22:12:46 -0500 Subject: [PATCH] feat(provider): support `local:interface` --- .golangci.yaml | 2 + README.markdown | 23 ++-- internal/api/cloudflare_waf.go | 4 +- internal/config/config.go | 6 +- internal/config/env_provider.go | 45 ++++--- internal/config/env_provider_test.go | 26 +++- internal/config/env_waf.go | 4 +- internal/pp/utils.go | 2 +- internal/provider/local_cloudflare.go | 2 +- internal/provider/local_iface.go | 11 ++ .../protocol/{local.go => local_auto.go} | 31 +++-- .../{local_test.go => local_auto_test.go} | 17 ++- internal/provider/protocol/local_iface.go | 102 ++++++++++++++ .../provider/protocol/local_iface_test.go | 125 ++++++++++++++++++ internal/provider/protocol/regexp.go | 2 +- internal/provider/protocol/split_dialer.go | 10 +- 16 files changed, 345 insertions(+), 67 deletions(-) create mode 100644 internal/provider/local_iface.go rename internal/provider/protocol/{local.go => local_auto.go} (56%) rename internal/provider/protocol/{local_test.go => local_auto_test.go} (95%) create mode 100644 internal/provider/protocol/local_iface.go create mode 100644 internal/provider/protocol/local_iface_test.go diff --git a/.golangci.yaml b/.golangci.yaml index 9ebef780..99a3b799 100644 --- a/.golangci.yaml +++ b/.golangci.yaml @@ -44,6 +44,8 @@ linters: - exportloopref # deprecated - dupl # somewhat unpredictable, and never leads to actual code changes + - goconst # never leads to actual code changes + - mnd # never leads to actual code changes - cyclop # can detect complicated code, but never leads to actual code changes - funlen # can detect complicated code, but never leads to actual code changes diff --git a/README.markdown b/README.markdown index 8b5ddb30..dccae7b1 100644 --- a/README.markdown +++ b/README.markdown @@ -297,22 +297,23 @@ _(Click to expand the following items.)_
๐Ÿ” IP address providers -| Name | Meaning | Default Value | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------ | -| `IP4_PROVIDER` | This specifies how to detect the current IPv4 address. Available providers include `cloudflare.doh`, `cloudflare.trace`, `local`, `url:`, and `none`. The special `none` provider disables IPv4 completely. See below for a detailed explanation. | `cloudflare.trace` | -| `IP6_PROVIDER` | This specifies how to detect the current IPv6 address. Available providers include `cloudflare.doh`, `cloudflare.trace`, `local`, `url:`, and `none`. The special `none` provider disables IPv6 completely. See below for a detailed explanation. | `cloudflare.trace` | +| Name | Meaning | Default Value | +| -------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------ | +| `IP4_PROVIDER` | This specifies how to detect the current IPv4 address. Available providers include `cloudflare.doh`, `cloudflare.trace`, `local`, `local:`, `url:`, and `none`. The special `none` provider disables IPv4 completely. See below for a detailed explanation. | `cloudflare.trace` | +| `IP6_PROVIDER` | This specifies how to detect the current IPv6 address. Available providers include `cloudflare.doh`, `cloudflare.trace`, `local`, `local:`, `url:`, and `none`. The special `none` provider disables IPv6 completely. See below for a detailed explanation. | `cloudflare.trace` | > ๐Ÿ‘‰ The option `IP4_PROVIDER` governs `A`-type DNS records and IPv4 addresses in WAF lists, while the option `IP6_PROVIDER` governs `AAAA`-type DNS records and IPv6 addresses in WAF lists. The two options act independently of each other. You can specify different address providers for IPv4 and IPv6. > ๐Ÿ“ก Available IP address providers: > -> | Provider Name | Explanation | -> | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -> | `cloudflare.doh` | Get the IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https). ๐Ÿค– The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay. | -> | `cloudflare.trace` | Get the IP address by parsing the [Cloudflare debugging page](https://one.one.one.one/cdn-cgi/trace). **This is the default provider.** ๐Ÿค– The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay. | -> | `local` | Get the IP address via local network interfaces. 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. (No data will be transmitted.) โš ๏ธ 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 default bridge network in Docker](https://docs.docker.com/network/bridge/) instead of those in the host network. | -> | `url:` | Fetch the content at `URL` and treat it 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 address from , a server maintained by [ipify](https://www.ipify.org). Note that the updater will only use IPv4 to connect to `URL` for fetching IPv4 addresses, and similarly only IPv6 for IPv6 addresses. Currently, only the HTTP(S) schema is supported. | -> | `none` | Stop the DNS updating for the specified IP version completely. For example `IP4_PROVIDER=none` will disable IPv4 completely. Existing DNS records will not be removed. โš ๏ธ The IP addresses of the disabled IP version will be removed from WAF lists; so `IP4_PROVIDER=none` will remove all IPv4 addresses from all managed WAF lists. ๐Ÿงช As the support of WAF lists is experimental, this behavior is subject to changes and please [provide feedback](https://github.com/favonia/cloudflare-ddns/issues/new). | +> | Provider Name | Explanation | +> | ------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +> | `cloudflare.doh` | Get the IP address by querying `whoami.cloudflare.` against [Cloudflare via DNS-over-HTTPS](https://developers.cloudflare.com/1.1.1.1/dns-over-https). ๐Ÿค– The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay. | +> | `cloudflare.trace` | Get the IP address by parsing the [Cloudflare debugging page](https://one.one.one.one/cdn-cgi/trace). **This is the default provider.** ๐Ÿค– The updater will connect `1.1.1.1` for IPv4 and `2606:4700:4700::1111` for IPv6. Since version 1.9.3, the updater will switch to `1.0.0.1` for IPv4 if `1.1.1.1` appears to be blocked or intercepted by your ISP or your router (which is still not uncommon). Since version 1.14.0, the blockage detection uses a variant of [the Happy Eyeballs algorithm](https://en.wikipedia.org/wiki/Happy_Eyeballs) to reduce delay. | +> | `local` | Get the IP address via local network interfaces. 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. (No data will be transmitted.) โš ๏ธ The updater needs access to the host network (such as `network_mode: host` in Docker Compose) for this provider, for otherwise the updater will detect the addresses inside [the default bridge network in Docker](https://docs.docker.com/network/bridge/) instead of those in the host network. | +> | `local:` | Get the IP address via the specific local network interface `iface`. When multiple IP address are assigned to the interface `iface`, the updater will choose the first global unicast IP address of the matching IP family (IPv4 or IPv6), or the first link-local unicast IP address of the matching family if it cannot find any global one. โš ๏ธ The updater needs access to the host network (such as `network_mode: host` in Docker Compose) for this provider, for otherwise the updater will detect the addresses inside [the default bridge network in Docker](https://docs.docker.com/network/bridge/) instead of those in the host network. | +> | `url:` | Fetch the content at `URL` and treat it 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 address from , a server maintained by [ipify](https://www.ipify.org). Note that the updater will only use IPv4 to connect to `URL` for fetching IPv4 addresses, and similarly only IPv6 for IPv6 addresses. Currently, only the HTTP(S) schema is supported. | +> | `none` | Stop the DNS updating for the specified IP version completely. For example `IP4_PROVIDER=none` will disable IPv4 completely. Existing DNS records will not be removed. โš ๏ธ The IP addresses of the disabled IP version will be removed from WAF lists; so `IP4_PROVIDER=none` will remove all IPv4 addresses from all managed WAF lists. ๐Ÿงช As the support of WAF lists is experimental, this behavior is subject to changes and please [provide feedback](https://github.com/favonia/cloudflare-ddns/issues/new). |
diff --git a/internal/api/cloudflare_waf.go b/internal/api/cloudflare_waf.go index 8523b9a1..15003b3d 100644 --- a/internal/api/cloudflare_waf.go +++ b/internal/api/cloudflare_waf.go @@ -22,8 +22,8 @@ import ( // - An IPv6 CIDR ranges with a prefix from /4 to /64 // For this updater, only the maximum values matter. var WAFListMaxBitLen = map[ipnet.Type]int{ //nolint:gochecknoglobals - ipnet.IP4: 32, //nolint:mnd - ipnet.IP6: 64, //nolint:mnd + ipnet.IP4: 32, + ipnet.IP6: 64, } func hintWAFListPermission(ppfmt pp.PP, err error) { diff --git a/internal/config/config.go b/internal/config/config.go index b62034e9..300db3ba 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -51,14 +51,14 @@ func Default() *Config { UpdateCron: cron.MustNew("@every 5m"), UpdateOnStart: true, DeleteOnStop: false, - CacheExpiration: time.Hour * 6, //nolint:mnd + CacheExpiration: time.Hour * 6, TTL: api.TTLAuto, ProxiedTemplate: "false", Proxied: map[domain.Domain]bool{}, RecordComment: "", WAFListDescription: "", - DetectionTimeout: time.Second * 5, //nolint:mnd - UpdateTimeout: time.Second * 30, //nolint:mnd + DetectionTimeout: time.Second * 5, + UpdateTimeout: time.Second * 30, Monitor: monitor.NewComposed(), Notifier: notifier.NewComposed(), } diff --git a/internal/config/env_provider.go b/internal/config/env_provider.go index 4d433d6a..f271d231 100644 --- a/internal/config/env_provider.go +++ b/internal/config/env_provider.go @@ -83,21 +83,26 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid return false } - switch val { - case "cloudflare": + parts := strings.SplitN(val, ":", 2) // len(parts) >= 1 because val is not empty + for i := range parts { + parts[i] = strings.TrimSpace(parts[i]) + } + + switch { + case len(parts) == 1 && parts[0] == "cloudflare": ppfmt.Noticef( pp.EmojiUserError, `%s=cloudflare is invalid; use %s=cloudflare.trace or %s=cloudflare.doh`, key, key, key, ) return false - case "cloudflare.trace": + case len(parts) == 1 && parts[0] == "cloudflare.trace": *field = provider.NewCloudflareTrace() return true - case "cloudflare.doh": + case len(parts) == 1 && parts[0] == "cloudflare.doh": *field = provider.NewCloudflareDOH() return true - case "ipify": + case len(parts) == 1 && parts[0] == "ipify": ppfmt.Noticef( pp.EmojiUserWarning, `%s=ipify is deprecated; use %s=cloudflare.trace or %s=cloudflare.doh`, @@ -105,25 +110,33 @@ func ReadProvider(ppfmt pp.PP, key, keyDeprecated string, field *provider.Provid ) *field = provider.NewIpify() return true - case "local": + case len(parts) == 1 && parts[0] == "local": *field = provider.NewLocal() return true - case "none": - *field = nil + case len(parts) == 2 && parts[0] == "local": + if parts[1] == "" { + ppfmt.Noticef( + pp.EmojiUserError, + `%s=local: must be followed by a network interface name`, + key, + ) + return false + } + *field = provider.NewLocalWithInterface(parts[1]) return true - } - - if strings.HasPrefix(val, "url:") { - url := strings.TrimSpace(strings.TrimPrefix(val, "url:")) - p, ok := provider.NewCustomURL(ppfmt, url) + case len(parts) == 2 && parts[0] == "url": + p, ok := provider.NewCustomURL(ppfmt, parts[1]) if ok { *field = p } return ok + case len(parts) == 1 && parts[0] == "none": + *field = nil + return true + default: + ppfmt.Noticef(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val) + return false } - - ppfmt.Noticef(pp.EmojiUserError, "%s (%q) is not a valid provider", key, val) - return false } // ReadProviderMap reads the environment variables IP4_PROVIDER and IP6_PROVIDER, diff --git a/internal/config/env_provider_test.go b/internal/config/env_provider_test.go index aba62f4c..4a230064 100644 --- a/internal/config/env_provider_test.go +++ b/internal/config/env_provider_test.go @@ -19,12 +19,13 @@ func TestReadProvider(t *testing.T) { keyDeprecated := keyPrefix + "DEPRECATED" var ( - none provider.Provider - doh = provider.NewCloudflareDOH() - trace = provider.NewCloudflareTrace() - local = provider.NewLocal() - ipify = provider.NewIpify() - custom = provider.MustNewCustomURL("https://url.io") + none provider.Provider + doh = provider.NewCloudflareDOH() + trace = provider.NewCloudflareTrace() + local = provider.NewLocal() + localLoopback = provider.NewLocalWithInterface("lo") + ipify = provider.NewIpify() + custom = provider.MustNewCustomURL("https://url.io") ) for name, tc := range map[string]struct { @@ -154,7 +155,18 @@ 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}, + "local:lo": {true, " local : lo ", false, "", trace, localLoopback, true, nil}, + "local:": { + true, " local: ", false, "", trace, trace, false, + func(m *mocks.MockPP) { + m.EXPECT().Noticef( + pp.EmojiUserError, + `%s=local: must be followed by a network interface name`, + key, + ) + }, + }, + "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/config/env_waf.go b/internal/config/env_waf.go index 2aa5f33e..2ad4aab7 100644 --- a/internal/config/env_waf.go +++ b/internal/config/env_waf.go @@ -32,8 +32,8 @@ func ReadAndAppendWAFListNames(ppfmt pp.PP, key string, field *[]api.WAFList) bo for _, val := range vals { var list api.WAFList - parts := strings.SplitN(val, "/", 2) //nolint:mnd - if len(parts) != 2 { //nolint:mnd + parts := strings.SplitN(val, "/", 2) + if len(parts) != 2 { ppfmt.Noticef(pp.EmojiUserError, `List %q should be in format "account-id/list-name"`, val) return false } diff --git a/internal/pp/utils.go b/internal/pp/utils.go index c3c08e04..b6371a37 100644 --- a/internal/pp/utils.go +++ b/internal/pp/utils.go @@ -25,7 +25,7 @@ func EnglishJoin(items []string) string { return "(none)" case 1: return items[0] - case 2: //nolint:mnd + case 2: return fmt.Sprintf("%s and %s", items[0], items[1]) default: return fmt.Sprintf("%s, and %s", strings.Join(items[:l-1], ", "), items[l-1]) diff --git a/internal/provider/local_cloudflare.go b/internal/provider/local_cloudflare.go index a033a989..78e7f552 100644 --- a/internal/provider/local_cloudflare.go +++ b/internal/provider/local_cloudflare.go @@ -8,7 +8,7 @@ import ( // NewLocal creates a specialized Local provider that uses Cloudflare as the remote server. // (No actual UDP packets will be sent to Cloudflare.) func NewLocal() Provider { - return protocol.Local{ + return protocol.LocalAuto{ ProviderName: "local", RemoteUDPAddr: map[ipnet.Type]string{ // 1.0.0.1 is used in case 1.1.1.1 is hijacked by the router diff --git a/internal/provider/local_iface.go b/internal/provider/local_iface.go new file mode 100644 index 00000000..2c9f5dfa --- /dev/null +++ b/internal/provider/local_iface.go @@ -0,0 +1,11 @@ +package provider + +import "github.com/favonia/cloudflare-ddns/internal/provider/protocol" + +// NewLocalWithInterface creates a protocol.LocalWithInterface provider. +func NewLocalWithInterface(iface string) Provider { + return protocol.LocalWithInterface{ + ProviderName: "local:" + iface, + InterfaceName: iface, + } +} diff --git a/internal/provider/protocol/local.go b/internal/provider/protocol/local_auto.go similarity index 56% rename from internal/provider/protocol/local.go rename to internal/provider/protocol/local_auto.go index 5191eec3..271e4732 100644 --- a/internal/provider/protocol/local.go +++ b/internal/provider/protocol/local_auto.go @@ -9,11 +9,11 @@ import ( "github.com/favonia/cloudflare-ddns/internal/pp" ) -// Local detects the IP address by pretending to send out an UDP packet +// LocalAuto detects the IP address by pretending to send out an UDP packet // and using the source IP address assigned by the system. In most cases // it will detect the IP address of the network interface toward the internet. // (No actual UDP packets will be sent out.) -type Local struct { +type LocalAuto struct { // Name of the detection protocol. ProviderName string @@ -22,29 +22,42 @@ type Local struct { } // Name of the detection protocol. -func (p Local) Name() string { +func (p LocalAuto) Name() string { return p.ProviderName } +// ExtractUDPAddr converts an address from [net.Interface.Addrs] to [netip.Addr]. +// The address will be unmapped. +func ExtractUDPAddr(ppfmt pp.PP, addr net.Addr) (netip.Addr, bool) { + switch v := addr.(type) { + case *net.UDPAddr: + return v.AddrPort().Addr().Unmap(), true + default: + ppfmt.Noticef(pp.EmojiImpossible, "Unexpected address data of type %T when detecting a local address", addr) + return netip.Addr{}, false + } +} + // GetIP detects the IP address by pretending to send an UDP packet. // (No actual UDP packets will be sent out.) -func (p Local) GetIP(_ context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) { - var invalidIP netip.Addr - +func (p LocalAuto) GetIP(_ context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) { remoteUDPAddr, found := p.RemoteUDPAddr[ipNet] if !found { ppfmt.Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe()) - return invalidIP, MethodUnspecified, false + return netip.Addr{}, MethodUnspecified, false } conn, err := net.Dial(ipNet.UDPNetwork(), remoteUDPAddr) if err != nil { ppfmt.Noticef(pp.EmojiError, "Failed to detect a local %s address: %v", ipNet.Describe(), err) - return invalidIP, MethodUnspecified, false + return netip.Addr{}, MethodUnspecified, false } defer conn.Close() - ip := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr() //nolint:forcetypeassert + ip, ok := ExtractUDPAddr(ppfmt, conn.LocalAddr()) + if !ok { + return netip.Addr{}, MethodUnspecified, false + } normalizedIP, ok := ipNet.NormalizeDetectedIP(ppfmt, ip) return normalizedIP, MethodPrimary, ok diff --git a/internal/provider/protocol/local_test.go b/internal/provider/protocol/local_auto_test.go similarity index 95% rename from internal/provider/protocol/local_test.go rename to internal/provider/protocol/local_auto_test.go index e3a04135..c9fcb011 100644 --- a/internal/provider/protocol/local_test.go +++ b/internal/provider/protocol/local_auto_test.go @@ -14,10 +14,10 @@ import ( "github.com/favonia/cloudflare-ddns/internal/provider/protocol" ) -func TestLocalName(t *testing.T) { +func TestLocalAuteName(t *testing.T) { t.Parallel() - p := &protocol.Local{ + p := &protocol.LocalAuto{ ProviderName: "very secret name", RemoteUDPAddr: nil, } @@ -25,7 +25,7 @@ func TestLocalName(t *testing.T) { require.Equal(t, "very secret name", p.Name()) } -func TestLocalGetIP(t *testing.T) { +func TestLocalAuteGetIP(t *testing.T) { t.Parallel() ip4Loopback := netip.MustParseAddr("127.0.0.1") @@ -101,18 +101,17 @@ func TestLocalGetIP(t *testing.T) { 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.Local{ + provider := &protocol.LocalAuto{ ProviderName: "", RemoteUDPAddr: map[ipnet.Type]string{ tc.addrKey: tc.addr, }, } - - mockPP := mocks.NewMockPP(mockCtrl) - if tc.prepareMockPP != nil { - tc.prepareMockPP(mockPP) - } ip, method, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet) require.True(t, tc.expected.Matches(ip)) require.NotEqual(t, protocol.MethodAlternative, method) diff --git a/internal/provider/protocol/local_iface.go b/internal/provider/protocol/local_iface.go new file mode 100644 index 00000000..462ba183 --- /dev/null +++ b/internal/provider/protocol/local_iface.go @@ -0,0 +1,102 @@ +package protocol + +import ( + "context" + "net" + "net/netip" + + "github.com/favonia/cloudflare-ddns/internal/ipnet" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +// LocalWithInterface detects the IP address by choosing the first "good" IP +// address assigned to a network interface. +type LocalWithInterface struct { + // Name of the detection protocol. + ProviderName string + + // The name of the network interface + InterfaceName string +} + +// Name of the detection protocol. +func (p LocalWithInterface) Name() string { + return p.ProviderName +} + +// ExtractInterfaceAddr converts an address from [net.Interface.Addrs] to [netip.Addr]. +// The address will be unmapped. +func ExtractInterfaceAddr(ppfmt pp.PP, addr net.Addr, iface string) (netip.Addr, bool) { + switch v := addr.(type) { + case *net.IPAddr: + ip, ok := netip.AddrFromSlice(v.IP) + if !ok { + ppfmt.Noticef(pp.EmojiImpossible, "Failed to parse address %q of interface %q", addr.String(), iface) + return netip.Addr{}, false + } + return ip.Unmap().WithZone(v.Zone), true + case *net.IPNet: + ip, ok := netip.AddrFromSlice(v.IP) + if !ok { + ppfmt.Noticef(pp.EmojiImpossible, "Failed to parse address %q of interface %s", addr.String(), iface) + return netip.Addr{}, false + } + ip = ip.Unmap() + if ip.Zone() == "" && (ip.IsInterfaceLocalMulticast() || ip.IsLinkLocalUnicast() || ip.IsLinkLocalMulticast()) { + ip = ip.WithZone(iface) + } + return ip, true + default: + ppfmt.Noticef(pp.EmojiImpossible, "Unexpected data %q of type %T in interface %s", addr.String(), addr, iface) + return netip.Addr{}, false + } +} + +// GetIP detects the IP address by pretending to send an UDP packet. +// (No actual UDP packets will be sent out.) +func (p LocalWithInterface) GetIP(_ context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) { + iface, err := net.InterfaceByName(p.InterfaceName) + if err != nil { + ppfmt.Noticef(pp.EmojiUserError, "Failed to find an interface named %q: %v", p.InterfaceName, err) + return netip.Addr{}, MethodUnspecified, false + } + + addrs, err := iface.Addrs() + if err != nil { + ppfmt.Noticef(pp.EmojiImpossible, "Failed to list addresses of interface %q: %v", p.InterfaceName, err) + return netip.Addr{}, MethodUnspecified, false + } + + var firstLinkLocalIP netip.Addr + for _, addr := range addrs { + ip, ok := ExtractInterfaceAddr(ppfmt, addr, p.InterfaceName) + if !ok { + return ip, MethodUnspecified, false + } + + // Skip all addresses in the wrong IP family. + if !ipNet.Matches(ip) { + continue + } + + // Choose the first unicast address of the global scope. + if ip.IsGlobalUnicast() { + return ip, MethodPrimary, true + } + + // Otherwise, remember the first link-local address. + if !firstLinkLocalIP.IsValid() && ip.IsLinkLocalUnicast() { + firstLinkLocalIP = ip + } + } + if firstLinkLocalIP.IsValid() { + ppfmt.Noticef(pp.EmojiWarning, + "Failed to find any global unicast %s address assigned to interface %s, but found a link-local address %s", + ipNet.Describe(), p.InterfaceName, firstLinkLocalIP.String()) + return firstLinkLocalIP, MethodPrimary, true + } + + ppfmt.Noticef(pp.EmojiError, "Failed to find any unicast %s address of interface %q", + ipNet.Describe(), p.InterfaceName) + return netip.Addr{}, MethodUnspecified, false +} diff --git a/internal/provider/protocol/local_iface_test.go b/internal/provider/protocol/local_iface_test.go new file mode 100644 index 00000000..f1d71002 --- /dev/null +++ b/internal/provider/protocol/local_iface_test.go @@ -0,0 +1,125 @@ +//go:build linux + +package protocol_test + +import ( + "context" + "net" + "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 TestLocalWithInterfaceName(t *testing.T) { + t.Parallel() + + p := &protocol.LocalWithInterface{ + ProviderName: "very secret name", + InterfaceName: "lo", + } + + require.Equal(t, "very secret name", p.Name()) +} + +func TestExtractInterfaceAddr(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + input net.Addr + ok bool + output netip.Addr + prepareMockPP func(*mocks.MockPP) + }{ + "ipaddr/4": { + &net.IPAddr{IP: net.ParseIP("127.0.0.1"), Zone: ""}, + true, netip.MustParseAddr("127.0.0.1"), + nil, + }, + "ipaddr/6/zone-123": { + &net.IPAddr{IP: net.ParseIP("::1"), Zone: "123"}, + true, netip.MustParseAddr("::1%123"), + nil, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + + mockCtrl := gomock.NewController(t) + mockPP := mocks.NewMockPP(mockCtrl) + if tc.prepareMockPP != nil { + tc.prepareMockPP(mockPP) + } + + output, ok := protocol.ExtractInterfaceAddr(mockPP, tc.input, "iface") + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.output, output) + }) + } +} + +func TestLocalWithInterfaceGetIP(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct { + interfaceName string + ipNet ipnet.Type + ok bool + expected netip.Addr + prepareMockPP func(*mocks.MockPP) + }{ + "lo/4": { + "lo", ipnet.IP4, false, + netip.Addr{}, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiError, + "Failed to find any unicast %s address of interface %q", + "IPv4", "lo") + }, + }, + "lo/6": { + "lo", ipnet.IP6, false, + netip.Addr{}, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiError, + "Failed to find any unicast %s address of interface %q", + "IPv6", "lo") + }, + }, + "non-existent": { + "non-existent-iface", ipnet.IP4, false, + netip.Addr{}, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiUserError, + "Failed to find an interface named %q: %v", + "non-existent-iface", gomock.Any(), + ) + }, + }, + } { + 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.LocalWithInterface{ + ProviderName: "", + InterfaceName: tc.interfaceName, + } + 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) + }) + } +} diff --git a/internal/provider/protocol/regexp.go b/internal/provider/protocol/regexp.go index 9e749396..589fca67 100644 --- a/internal/provider/protocol/regexp.go +++ b/internal/provider/protocol/regexp.go @@ -22,7 +22,7 @@ func getIPFromRegexp(ctx context.Context, ppfmt pp.PP, ipNet ipnet.Type, url str var invalidIP netip.Addr matched := re.FindSubmatch(body) - if len(matched) < 2 { //nolint:mnd + if len(matched) < 2 { ppfmt.Noticef(pp.EmojiError, `Failed to find the IP address in the response of %q: %s`, url, body) return invalidIP, false } diff --git a/internal/provider/protocol/split_dialer.go b/internal/provider/protocol/split_dialer.go index a7766e27..ad61f138 100644 --- a/internal/provider/protocol/split_dialer.go +++ b/internal/provider/protocol/split_dialer.go @@ -35,8 +35,8 @@ func filterIP4Only(_ context.Context, network, _ string, _ syscall.RawConn) erro func newControlledDialer(control func(context.Context, string, string, syscall.RawConn) error) *net.Dialer { return &net.Dialer{ //nolint:exhaustruct - Timeout: 30 * time.Second, //nolint:mnd - KeepAlive: 30 * time.Second, //nolint:mnd + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, ControlContext: control, } } @@ -46,9 +46,9 @@ func newControlledTransport(control func(context.Context, string, string, syscal Proxy: http.ProxyFromEnvironment, DialContext: newControlledDialer(control).DialContext, ForceAttemptHTTP2: true, - MaxIdleConns: 100, //nolint:mnd - IdleConnTimeout: 90 * time.Second, //nolint:mnd - TLSHandshakeTimeout: 10 * time.Second, //nolint:mnd + MaxIdleConns: 100, + IdleConnTimeout: 90 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, ExpectContinueTimeout: 1 * time.Second, } }