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..932561ed 100644 --- a/README.markdown +++ b/README.markdown @@ -274,14 +274,14 @@ _(Click to expand the following items.)_
📍 DNS domains and WAF lists to update -> You need to specify at least one thing in `DOMAINS`, `IP4_DOMAINS`, `IP6_DOMAINS`, or 🧪 `WAF_LISTS` for the updater to update. +> You need to specify at least one thing in `DOMAINS`, `IP4_DOMAINS`, `IP6_DOMAINS`, or 🧪 `WAF_LISTS` (since 1.14.0) for the updater to update. -| Name | Meaning | -| -------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for both `A` and `AAAA` records. Listing a domain in `DOMAINS` is equivalent to listing the same domain in both `IP4_DOMAINS` and `IP6_DOMAINS`. | -| `IP4_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for `A` records | -| `IP6_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for `AAAA` records | -| 🧪 `WAF_LISTS` | 🧪 Comma-separated references of [WAF lists](https://developers.cloudflare.com/waf/tools/lists/custom-lists/) the updater should manage. A list reference is written in the format `/` where `account-id` is your account ID and `list-name` is the list name; it should look like `0123456789abcdef0123456789abcdef/mylist`. If the referenced WAF list does not exist, the updater will try to create it. 💡 See [how to find your account ID](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/). 🧪 This feature to manipulate WAF lists is highly experimental (introduced in 1.14.0) and is subject to changes. Please [open a GitHub issue](https://github.com/favonia/cloudflare-ddns/issues/new) to provide feedback. Thanks! | +| Name | Meaning | +| ----------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for both `A` and `AAAA` records. Listing a domain in `DOMAINS` is equivalent to listing the same domain in both `IP4_DOMAINS` and `IP6_DOMAINS`. | +| `IP4_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for `A` records | +| `IP6_DOMAINS` | Comma-separated fully qualified domain names or wildcard domain names that the updater should manage for `AAAA` records | +| 🧪 `WAF_LISTS` (since 1.14.0) | 🧪 Comma-separated references of [WAF lists](https://developers.cloudflare.com/waf/tools/lists/custom-lists/) the updater should manage. A list reference is written in the format `/` where `account-id` is your account ID and `list-name` is the list name; it should look like `0123456789abcdef0123456789abcdef/mylist`. If the referenced WAF list does not exist, the updater will try to create it. 💡 See [how to find your account ID](https://developers.cloudflare.com/fundamentals/setup/find-account-and-zone-ids/). 🧪 This feature to manipulate WAF lists is experimental (introduced in 1.14.0). Please [open a GitHub issue](https://github.com/favonia/cloudflare-ddns/issues/new) to provide feedback. Thanks! | > 🃏🤖 **Wildcard domains** (`*.example.org`) represent all subdomains that _would not exist otherwise._ Therefore, if you have another subdomain entry `sub.example.org`, the wildcard domain is independent of it, because it only represents the _other_ subdomains which do not have their own entries. Also, you can only have one layer of `*`---`*.*.example.org` would not work. @@ -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:` (since 1.15.0) | 🧪 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). ⚠️ The updater needs access to the host network (such as `network_mode: host` in Docker Compose) for this provider, for otherwise the updater cannot access host network interfaces. | +> | `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 . 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). |
@@ -342,16 +343,16 @@ _(Click to expand the following items.)_
🐣 Parameters of new DNS records and WAF lists -> 👉 The updater will preserve existing parameters (TTL, proxy states, DNS record comments, etc.). Only when it creates new DNS records and new WAF lists, the following settings will apply. To change existing parameters, you can go to your [Cloudflare Dashboard](https://dash.cloudflare.com) and change them directly. If you think you have a use case where the updater should actively overwrite existing parameters in addition to IP addresses, please [let me know](https://github.com/favonia/cloudflare-ddns/issues/new). 🐞🧪 **KNOWN ISSUE: existing comments attached to stale WAF list items (not WAF lists themselves) will not be transferred to new list items for new IP addresses** because the Cloudflare API does not provide an easy way to update them. The comments will be lost when the updater deletes stale list items and create new ones. +> 👉 The updater will preserve existing parameters (TTL, proxy states, DNS record comments, etc.). Only when it creates new DNS records and new WAF lists, the following settings will apply. To change existing parameters, you can go to your [Cloudflare Dashboard](https://dash.cloudflare.com) and change them directly. If you think you have a use case where the updater should actively overwrite existing parameters in addition to IP addresses, please [let me know](https://github.com/favonia/cloudflare-ddns/issues/new). 🐞🧪 **KNOWN ISSUE: comments of stale WAF list items (not WAF lists themselves) will not be kept** because the Cloudflare API does not provide an easy way to update list items. The comments will be lost when the updater deletes stale list items and create new ones. -| Name | Meaning | Default Value | -| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | -| `PROXIED` | Whether new DNS records should be proxied by Cloudflare. It can be any boolean value accepted by [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool), such as `true`, `false`, `0` or `1`. 🧪 It can also be a domain-dependent boolean expression as described below. | `false` | -| `TTL` | The time-to-live (TTL) (in seconds) of new DNS records. | `1` (This means “automatic” to Cloudflare) | -| `RECORD_COMMENT` | The [record comment](https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/) of new DNS records. | `""` | -| 🧪 `WAF_LIST_DESCRIPTION` | 🧪 The text description of new WAF lists. | `""` | +| Name | Meaning | Default Value | +| ---------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------ | +| `PROXIED` | Whether new DNS records should be proxied by Cloudflare. It can be any boolean value accepted by [strconv.ParseBool](https://pkg.go.dev/strconv#ParseBool), such as `true`, `false`, `0` or `1`. 🤖 Advanced usage: it can also be a domain-dependent boolean expression as described below. | `false` | +| `TTL` | The time-to-live (TTL) (in seconds) of new DNS records. | `1` (This means “automatic” to Cloudflare) | +| `RECORD_COMMENT` | The [record comment](https://developers.cloudflare.com/dns/manage-dns-records/reference/record-attributes/) of new DNS records. | `""` | +| 🧪 `WAF_LIST_DESCRIPTION` (since 1.14.0) | 🧪 The text description of new WAF lists. | `""` | -> 🤖🧪 For advanced users: the `PROXIED` can be a boolean expression involving domains! This allows you to enable Cloudflare proxying for some domains but not the others. Here are some example expressions: +> 🤖 For advanced users: the `PROXIED` can be a boolean expression involving domains! This allows you to enable Cloudflare proxying for some domains but not the others. Here are some example expressions: > > - `PROXIED=is(example.org)`: proxy only the domain `example.org` > - `PROXIED=is(example1.org) || sub(example2.org)`: proxy only the domain `example1.org` and subdomains of `example2.org` @@ -397,13 +398,11 @@ _(Click to expand the following items.)_
📣 External notifications (Healthchecks, Uptime Kuma, and shoutrrr) -> 🧪 The integration with `shoutrrr` is still somewhat experimental (introduced in 1.12.0). - -| Name | Meaning | -| -------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `HEALTHCHECKS` | The [Healthchecks ping URL](https://healthchecks.io/docs/) to ping when the updater successfully updates IP addresses, such as `https://hc-ping.com/` or `https://hc-ping.com//` ⚠️ The ping schedule should match the update schedule specified by `UPDATE_CRON`. 🤖 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. | -| `UPTIMEKUMA` | The Uptime Kuma’s Push URL to ping when the updater successfully updates IP addresses, such as `https:///push/`. You can directly copy the “Push URL” from the Uptime Kuma configuration page. ⚠️ Remember to change the “Heartbeat Interval” to match the update schedule specified by `UPDATE_CRON`. | -| 🧪 `SHOUTRRR` | 🧪 A list of notifications services the updater should send messages to when it updates IP addresses. The format is newline-separated [shoutrrr URLs](https://containrrr.dev/shoutrrr/latest/services/overview/), such as `discord://@`. | +| Name | Meaning | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `HEALTHCHECKS` | The [Healthchecks ping URL](https://healthchecks.io/docs/) to ping when the updater successfully updates IP addresses, such as `https://hc-ping.com/` or `https://hc-ping.com//` ⚠️ The ping schedule should match the update schedule specified by `UPDATE_CRON`. 🤖 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. | +| `UPTIMEKUMA` | The Uptime Kuma’s Push URL to ping when the updater successfully updates IP addresses, such as `https:///push/`. You can directly copy the “Push URL” from the Uptime Kuma configuration page. ⚠️ Remember to change the “Heartbeat Interval” to match the update schedule specified by `UPDATE_CRON`. | +| 🧪 `SHOUTRRR` (since 1.12.0) | 🧪 A list of notifications services the updater should send messages to when it updates IP addresses. The format is newline-separated [shoutrrr URLs](https://containrrr.dev/shoutrrr/latest/services/overview/), such as `discord://@`. | > ⚠️ Please note that a failure in handling IPv6 will cause the status to be reported as _down_ even if IPv4 records are updated successfully (and similarly if IPv6 works but IPv4 fails). If your network does not support IPv6, add `IP6_PROVIDER=none` to disable IPv6 completely. 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..a2b0f86f 100644 --- a/internal/provider/local_cloudflare.go +++ b/internal/provider/local_cloudflare.go @@ -1,19 +1,12 @@ package provider -import ( - "github.com/favonia/cloudflare-ddns/internal/ipnet" - "github.com/favonia/cloudflare-ddns/internal/provider/protocol" -) +import "github.com/favonia/cloudflare-ddns/internal/provider/protocol" // 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{ - ProviderName: "local", - RemoteUDPAddr: map[ipnet.Type]string{ - // 1.0.0.1 is used in case 1.1.1.1 is hijacked by the router - ipnet.IP4: "1.0.0.1:443", - ipnet.IP6: "[2606:4700:4700::1111]:443", - }, + return protocol.LocalAuto{ + ProviderName: "local", + RemoteUDPAddr: "api.cloudflare.com:443", } } 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.go deleted file mode 100644 index 5191eec3..00000000 --- a/internal/provider/protocol/local.go +++ /dev/null @@ -1,51 +0,0 @@ -package protocol - -import ( - "context" - "net" - "net/netip" - - "github.com/favonia/cloudflare-ddns/internal/ipnet" - "github.com/favonia/cloudflare-ddns/internal/pp" -) - -// Local 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 { - // Name of the detection protocol. - ProviderName string - - // The target IP address of the UDP packet to be sent. - RemoteUDPAddr map[ipnet.Type]string -} - -// Name of the detection protocol. -func (p Local) Name() string { - return p.ProviderName -} - -// 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 - - remoteUDPAddr, found := p.RemoteUDPAddr[ipNet] - if !found { - ppfmt.Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", ipNet.Describe()) - return invalidIP, 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 - } - defer conn.Close() - - ip := conn.LocalAddr().(*net.UDPAddr).AddrPort().Addr() //nolint:forcetypeassert - - normalizedIP, ok := ipNet.NormalizeDetectedIP(ppfmt, ip) - return normalizedIP, MethodPrimary, ok -} diff --git a/internal/provider/protocol/local_auto.go b/internal/provider/protocol/local_auto.go new file mode 100644 index 00000000..2235a517 --- /dev/null +++ b/internal/provider/protocol/local_auto.go @@ -0,0 +1,63 @@ +package protocol + +import ( + "context" + "net" + "net/netip" + + "github.com/favonia/cloudflare-ddns/internal/ipnet" + "github.com/favonia/cloudflare-ddns/internal/pp" +) + +// 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 LocalAuto struct { + // Name of the detection protocol. + ProviderName string + + // The target of the hypothetical UDP packet to be sent. + RemoteUDPAddr string +} + +// Name of the detection protocol. +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: + ip := v.AddrPort().Addr().Unmap() + if !ip.IsValid() { + ppfmt.Noticef(pp.EmojiImpossible, "Failed to parse UDP source address %q", v.IP.String()) + return netip.Addr{}, false + } + return ip, ip.IsValid() + default: + ppfmt.Noticef(pp.EmojiImpossible, "Unexpected UDP source address data %q of type %T", addr.String(), 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 LocalAuto) GetIP(_ context.Context, ppfmt pp.PP, ipNet ipnet.Type) (netip.Addr, Method, bool) { + conn, err := net.Dial(ipNet.UDPNetwork(), p.RemoteUDPAddr) + if err != nil { + ppfmt.Noticef(pp.EmojiError, "Failed to detect a local %s address: %v", ipNet.Describe(), err) + return netip.Addr{}, MethodUnspecified, false + } + defer conn.Close() + + 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 54% rename from internal/provider/protocol/local_test.go rename to internal/provider/protocol/local_auto_test.go index a3346202..caca9db5 100644 --- a/internal/provider/protocol/local_test.go +++ b/internal/provider/protocol/local_auto_test.go @@ -2,6 +2,7 @@ package protocol_test import ( "context" + "net" "net/netip" "testing" @@ -14,24 +15,79 @@ 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, + RemoteUDPAddr: "", } require.Equal(t, "very secret name", p.Name()) } -func TestLocalGetIP(t *testing.T) { +func TestExtractUDPAddr(t *testing.T) { + t.Parallel() + + var invalidIP netip.Addr + + for name, tc := range map[string]struct { + input net.Addr + ok bool + output netip.Addr + prepareMockPP func(*mocks.MockPP) + }{ + "udpaddr/4": { + &net.UDPAddr{IP: net.ParseIP("1.2.3.4"), Zone: "", Port: 123}, + true, netip.MustParseAddr("1.2.3.4"), + nil, + }, + "udpaddr/6/zone-123": { + &net.UDPAddr{IP: net.ParseIP("::1"), Zone: "123", Port: 123}, + true, netip.MustParseAddr("::1%123"), + nil, + }, + "udpaddr/illformed": { + &net.UDPAddr{IP: net.IP([]byte{0x01, 0x02}), Zone: "", Port: 123}, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, + "Failed to parse UDP source address %q", + "?0102") + }, + }, + "dummy": { + &Dummy{}, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, + "Unexpected UDP source address data %q of type %T", + "dummy/string", &Dummy{}) + }, + }, + } { + 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.ExtractUDPAddr(mockPP, tc.input) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.output, output) + }) + } +} + +func TestLocalAuteGetIP(t *testing.T) { t.Parallel() invalidIP := netip.Addr{} for name, tc := range map[string]struct { - addrKey ipnet.Type addr string ipNet ipnet.Type ok bool @@ -39,7 +95,7 @@ func TestLocalGetIP(t *testing.T) { prepareMockPP func(*mocks.MockPP) }{ "loopback/4": { - ipnet.IP4, "127.0.0.1:80", ipnet.IP4, + "127.0.0.1:80", ipnet.IP4, false, invalidIP, func(m *mocks.MockPP) { m.EXPECT().Noticef(pp.EmojiError, @@ -47,7 +103,7 @@ func TestLocalGetIP(t *testing.T) { }, }, "loopback/6": { - ipnet.IP6, "[::1]:80", ipnet.IP6, + "[::1]:80", ipnet.IP6, false, invalidIP, func(m *mocks.MockPP) { m.EXPECT().Noticef(pp.EmojiError, @@ -55,49 +111,32 @@ func TestLocalGetIP(t *testing.T) { }, }, "empty/4": { - ipnet.IP4, "", ipnet.IP4, + "", ipnet.IP4, false, invalidIP, func(m *mocks.MockPP) { m.EXPECT().Noticef(pp.EmojiError, "Failed to detect a local %s address: %v", "IPv4", gomock.Any()) }, }, "empty/6": { - ipnet.IP6, "", ipnet.IP6, + "", ipnet.IP6, false, invalidIP, func(m *mocks.MockPP) { m.EXPECT().Noticef(pp.EmojiError, "Failed to detect a local %s address: %v", "IPv6", gomock.Any()) }, }, - "mismatch/6": { - ipnet.IP4, "127.0.0.1:80", ipnet.IP6, - false, invalidIP, - func(m *mocks.MockPP) { - m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv6") - }, - }, - "mismatch/4": { - ipnet.IP6, "::1:80", ipnet.IP4, - false, invalidIP, - func(m *mocks.MockPP) { - m.EXPECT().Noticef(pp.EmojiImpossible, "Unhandled IP network: %s", "IPv4") - }, - }, } { t.Run(name, func(t *testing.T) { t.Parallel() mockCtrl := gomock.NewController(t) - - provider := &protocol.Local{ - ProviderName: "", - RemoteUDPAddr: map[ipnet.Type]string{ - tc.addrKey: tc.addr, - }, - } - mockPP := mocks.NewMockPP(mockCtrl) if tc.prepareMockPP != nil { tc.prepareMockPP(mockPP) } + + provider := &protocol.LocalAuto{ + ProviderName: "", + RemoteUDPAddr: tc.addr, + } ip, method, ok := provider.GetIP(context.Background(), mockPP, tc.ipNet) require.Equal(t, tc.expected, 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..7b75dea9 --- /dev/null +++ b/internal/provider/protocol/local_iface.go @@ -0,0 +1,110 @@ +package protocol + +import ( + "context" + "net" + "net/netip" + "slices" + + "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, iface string, addr net.Addr) (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 assigned to interface %s", v.IP.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 assigned to interface %s", v.IP.String(), iface) + return netip.Addr{}, false + } + return ip.Unmap(), true + default: + ppfmt.Noticef(pp.EmojiImpossible, + "Unexpected address data %q of type %T found in interface %s", + addr.String(), addr, iface) + return netip.Addr{}, false + } +} + +// SelectInterfaceIP takes a list of [net.Addr] and choose the first reasonable IP (if any). +func SelectInterfaceIP(ppfmt pp.PP, iface string, ipNet ipnet.Type, addrs []net.Addr) (netip.Addr, Method, bool) { + ips := make([]netip.Addr, 0, len(addrs)) + for _, addr := range addrs { + ip, ok := ExtractInterfaceAddr(ppfmt, iface, addr) + if !ok { + return ip, MethodUnspecified, false + } + ips = append(ips, ip) + } + + i := slices.IndexFunc(ips, func(ip netip.Addr) bool { + return ipNet.Matches(ip) && ip.IsGlobalUnicast() + }) + if i >= 0 { + return ips[i], MethodPrimary, true + } + + // Choose an IP that is above the link-local scope + i = slices.IndexFunc(ips, func(ip netip.Addr) bool { + return ipNet.Matches(ip) && + !ip.IsUnspecified() && + !ip.IsLoopback() && + !ip.IsInterfaceLocalMulticast() && + !ip.IsLinkLocalUnicast() && !ip.IsLinkLocalMulticast() + }) + if i >= 0 { + ppfmt.Noticef(pp.EmojiWarning, + "Failed to find any global unicast %s address assigned to interface %s, "+ + "but found an address %s with a scope larger than the link-local scope", + ipNet.Describe(), iface, ips[i].String()) + return ips[i], MethodPrimary, true + } + + ppfmt.Noticef(pp.EmojiError, + "Failed to find any global unicast %s address assigned to interface %s", + ipNet.Describe(), iface) + return netip.Addr{}, MethodUnspecified, 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 %s: %v", p.InterfaceName, err) + return netip.Addr{}, MethodUnspecified, false + } + + return SelectInterfaceIP(ppfmt, p.InterfaceName, ipNet, addrs) +} diff --git a/internal/provider/protocol/local_iface_test.go b/internal/provider/protocol/local_iface_test.go new file mode 100644 index 00000000..517b61af --- /dev/null +++ b/internal/provider/protocol/local_iface_test.go @@ -0,0 +1,250 @@ +//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()) +} + +type Dummy struct{} + +func (*Dummy) Network() string { return "dummy/network" } +func (*Dummy) String() string { return "dummy/string" } + +func TestExtractInterfaceAddr(t *testing.T) { + t.Parallel() + + var invalidIP netip.Addr + + 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("1.2.3.4"), Zone: ""}, + true, netip.MustParseAddr("1.2.3.4"), + nil, + }, + "ipaddr/6/zone-123": { + &net.IPAddr{IP: net.ParseIP("::1"), Zone: "123"}, + true, netip.MustParseAddr("::1%123"), + nil, + }, + "ipaddr/illformed": { + &net.IPAddr{IP: net.IP([]byte{0x01, 0x02}), Zone: ""}, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, + "Failed to parse address %q assigned to interface %s", + "?0102", "iface") + }, + }, + "ipnet/4": { + &net.IPNet{IP: net.ParseIP("1.2.3.4"), Mask: net.CIDRMask(10, 22)}, + true, netip.MustParseAddr("1.2.3.4"), + nil, + }, + "ipnet/illformed": { + &net.IPNet{IP: net.IP([]byte{0x01, 0x02}), Mask: net.CIDRMask(10, 22)}, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, + "Failed to parse address %q assigned to interface %s", + "?0102", "iface") + }, + }, + "dummy": { + &Dummy{}, + false, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, + "Unexpected address data %q of type %T found in interface %s", + "dummy/string", &Dummy{}, "iface") + }, + }, + } { + 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, "iface", tc.input) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.output, output) + }) + } +} + +func TestSelectInterfaceIP(t *testing.T) { + t.Parallel() + + var invalidIP netip.Addr + + for name, tc := range map[string]struct { + ipNet ipnet.Type + input []net.Addr + ok bool + method protocol.Method + output netip.Addr + prepareMockPP func(*mocks.MockPP) + }{ + "ipaddr/4/6+4": { + ipnet.IP4, + []net.Addr{ + &net.IPAddr{IP: net.ParseIP("1::1"), Zone: ""}, + &net.IPAddr{IP: net.ParseIP("1.2.3.4"), Zone: ""}, + &net.IPAddr{IP: net.ParseIP("4.3.2.1"), Zone: ""}, + &net.IPAddr{IP: net.ParseIP("2::2"), Zone: ""}, + }, + true, protocol.MethodPrimary, netip.MustParseAddr("1.2.3.4"), + nil, + }, + "ipaddr/4/none": { + ipnet.IP4, + []net.Addr{&net.IPAddr{IP: net.ParseIP("1::1"), Zone: ""}, &net.IPAddr{IP: net.ParseIP("2::2"), Zone: ""}}, + false, protocol.MethodUnspecified, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiError, + "Failed to find any global unicast %s address assigned to interface %s", + "IPv4", "iface", + ) + }, + }, + "ipaddr/4/loopback": { + ipnet.IP4, + []net.Addr{&net.IPAddr{IP: net.ParseIP("127.0.0.1"), Zone: ""}}, + false, protocol.MethodUnspecified, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiError, + "Failed to find any global unicast %s address assigned to interface %s", + "IPv4", "iface", + ) + }, + }, + "ipaddr/6/ff05::2": { + ipnet.IP6, + []net.Addr{&net.IPAddr{IP: net.ParseIP("ff05::2"), Zone: "site"}}, + true, protocol.MethodPrimary, netip.MustParseAddr("ff05::2%site"), + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiWarning, + "Failed to find any global unicast %s address assigned to interface %s, "+ + "but found an address %s with a scope larger than the link-local scope", + "IPv6", "iface", "ff05::2%site", + ) + }, + }, + "ipaddr/4/dummy": { + ipnet.IP6, + []net.Addr{&Dummy{}}, + false, protocol.MethodUnspecified, invalidIP, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiImpossible, + "Unexpected address data %q of type %T found in interface %s", + "dummy/string", &Dummy{}, "iface") + }, + }, + } { + 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, method, ok := protocol.SelectInterfaceIP(mockPP, "iface", tc.ipNet, tc.input) + require.Equal(t, tc.ok, ok) + require.Equal(t, tc.method, method) + 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 global unicast %s address assigned to interface %s", + "IPv4", "lo") + }, + }, + "lo/6": { + "lo", ipnet.IP6, false, + netip.Addr{}, + func(ppfmt *mocks.MockPP) { + ppfmt.EXPECT().Noticef(pp.EmojiError, + "Failed to find any global unicast %s address assigned to interface %s", + "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, } }