From d21160e3718328bbc4f750bdf34beeca57ab3077 Mon Sep 17 00:00:00 2001 From: Andy 'Channie' Chan Date: Tue, 21 Jul 2026 21:35:40 +0100 Subject: [PATCH] feat: force ipv6 via api6 call rather than api64 We've been investigating some issues around IPV6 users seeing "flip flop" behaviour with the cli intermittently returning an IPV4 address via the api64 endpoint, where it should be only returning an ipv6 for that user, from what we can see this is potentially an intermittent issue with the api64 api so are forcing all users to hit the api6 endpoint, for ipv6 users with will be valid and for ipv4 it will through an nxdomain error which we will treat as "the user has no ipv6" Other changes - rename of GetMyIPs2() to GetMyIPs() - removal of the checkGotIP() function in favour of using the standard library netip package for validation of ipv4 and ipv6 addresses, with optional CIDR suffixes. - consistent ip validation across derived and defaulted in the config --- command/remote_access.go | 15 ++++++-- config/config.go | 74 +++++++++++++++++++++++++++++----------- 2 files changed, 66 insertions(+), 23 deletions(-) diff --git a/command/remote_access.go b/command/remote_access.go index acc7c08..6d05953 100644 --- a/command/remote_access.go +++ b/command/remote_access.go @@ -28,9 +28,18 @@ func buildRemoteAccessPayload(cfg *config.Config, action string) ([]byte, error) if cfg.IPv6Address != nil && *cfg.IPv6Address != "" { ipv6 = *cfg.IPv6Address } + + // Validate any explicitly-provided IPs + if ipv4 != "" && !config.IsValidIPv4(ipv4) { + return nil, fmt.Errorf("invalid IPv4 address: %q", ipv4) + } + if ipv6 != "" && !config.IsValidIPv6(ipv6) { + return nil, fmt.Errorf("invalid IPv6 address: %q", ipv6) + } + if ipv4 == "" && ipv6 == "" { var err error - ipv4, ipv6, err = cfg.GetMyIPs2() + ipv4, ipv6, err = cfg.GetMyIPs() if err != nil { return nil, err } @@ -181,7 +190,7 @@ func remoteAllowCommand(ctx context.Context, cfg *config.Config) *cobra.Command // Build payload payload, err := buildRemoteAccessPayload(cfg, "add") if err != nil { - out.Warn(fmt.Sprintf("Warning: %v. Aborting allow.", err)) + out.ErrorFHighlight(" %s %v", "✗", err) return nil } @@ -241,7 +250,7 @@ func remoteDenyCommand(ctx context.Context, cfg *config.Config) *cobra.Command { // Build payload with action revoke payload, err := buildRemoteAccessPayload(cfg, "revoke") if err != nil { - out.Warn(fmt.Sprintf("Warning: %v. Aborting deny.", err)) + out.ErrorFHighlight(" %s %v", "✗", err) return nil } diff --git a/config/config.go b/config/config.go index 3a3b43e..24c1f82 100644 --- a/config/config.go +++ b/config/config.go @@ -5,9 +5,9 @@ import ( "fmt" "io" "net/http" + "net/netip" "os" "path/filepath" - "regexp" "strings" "time" @@ -143,24 +143,46 @@ func Dump() ([]byte, error) { return data, nil } -func (cfg Config) checkGotIP(ip string) (bool, error) { - return regexp.MatchString(`^\d{1,3}(?:\.\d{1,3}){3}(?:/\d{1,2})?$`, ip) +// IsValidIPv4 validates an IPv4 address (with optional CIDR suffix) using the standard library. +func IsValidIPv4(ip string) bool { + // Try as a CIDR prefix first (e.g. "1.2.3.4/32") + if prefix, err := netip.ParsePrefix(ip); err == nil { + return prefix.Addr().Is4() + } + // Try as a plain address + if addr, err := netip.ParseAddr(ip); err == nil { + return addr.Is4() + } + return false +} + +// IsValidIPv6 validates an IPv6 address (with optional CIDR suffix) using the standard library. +func IsValidIPv6(ip string) bool { + // Try as a CIDR prefix first (e.g. "2001:db8::/64") + if prefix, err := netip.ParsePrefix(ip); err == nil { + return prefix.Addr().Is6() + } + // Try as a plain address + if addr, err := netip.ParseAddr(ip); err == nil { + return addr.Is6() + } + return false } // GetMyIP returns first IP in: `--ip` flag, `MY_IP` env var, config file, external service func (cfg Config) GetMyIP() (string, error) { // flag or config-file used? if cfg.IPAddress != nil && *cfg.IPAddress != "" { - if isValidIP, err := cfg.checkGotIP(*cfg.IPAddress); err != nil || !isValidIP { - return "", fmt.Errorf("unexpected format for IP (from --ip flag or config-file): %w", err) + if !IsValidIPv4(*cfg.IPAddress) { + return "", fmt.Errorf("unexpected format for IP (from --ip flag or config-file): %q", *cfg.IPAddress) } return *cfg.IPAddress, nil } // env var used? if ip := os.Getenv("MY_IP"); ip != "" { - if isValidIP, err := cfg.checkGotIP(ip); err != nil || !isValidIP { - return "", fmt.Errorf("unexpected format for env var MY_IP: %w", err) + if !IsValidIPv4(ip) { + return "", fmt.Errorf("unexpected format for env var MY_IP: %q", ip) } return ip, nil } @@ -188,15 +210,16 @@ func (cfg Config) GetMyIP() (string, error) { return "", err } - if isValidIP, err := cfg.checkGotIP(string(b)); err != nil || !isValidIP { - return "", fmt.Errorf("unexpected format for IP result from IP service: %w", err) + ip := string(b) + if !IsValidIPv4(ip) { + return "", fmt.Errorf("unexpected format for IP result from IP service: %q", ip) } - return string(b), nil + return ip, nil } -// GetMyIPs2 returns both IPv4 and IPv6 addresses, checking config > cli > env, then external service if needed. -func (cfg Config) GetMyIPs2() (ipv4, ipv6 string, err error) { +// GetMyIPs returns both IPv4 and IPv6 addresses, checking config > env, then external service if needed. +func (cfg Config) GetMyIPs() (ipv4, ipv6 string, err error) { // 1. Check config/env for explicit values if cfg.IPv4Address != nil && *cfg.IPv4Address != "" { ipv4 = *cfg.IPv4Address @@ -212,7 +235,15 @@ func (cfg Config) GetMyIPs2() (ipv4, ipv6 string, err error) { ipv6 = ip } - // 2. If not set, fetch from external service + // 2. Validate any explicitly-provided values + if ipv4 != "" && !IsValidIPv4(ipv4) { + return "", "", fmt.Errorf("invalid IPv4 address from config/env: %q", ipv4) + } + if ipv6 != "" && !IsValidIPv6(ipv6) { + return "", "", fmt.Errorf("invalid IPv6 address from config/env: %q", ipv6) + } + + // 3. If not set, fetch from external service if ipv4 == "" { req4, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api.ipify.org", http.NoBody) res, err4 := httpClient.Do(req4) @@ -220,30 +251,33 @@ func (cfg Config) GetMyIPs2() (ipv4, ipv6 string, err error) { b, errRead := io.ReadAll(res.Body) res.Body.Close() if errRead == nil { - s := string(b) - if isValidIP, _ := cfg.checkGotIP(s); isValidIP { + s := strings.TrimSpace(string(b)) + if IsValidIPv4(s) { ipv4 = s } } } } if ipv6 == "" { - req6, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api64.ipify.org", http.NoBody) + // api6.ipify.org is an IPv6-only endpoint (AAAA records only). + // If the user has no IPv6 connectivity, the request will fail + // (DNS resolution or connection error), which cleanly indicates + // "no IPv6 available" rather than silently returning an IPv4. + req6, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, "https://api6.ipify.org", http.NoBody) res, err6 := httpClient.Do(req6) if err6 == nil && res.StatusCode == 200 { b, errRead := io.ReadAll(res.Body) res.Body.Close() if errRead == nil { - s := string(b) - // TODO: improve IPv6 validation - if s != "" { + s := strings.TrimSpace(string(b)) + if IsValidIPv6(s) { ipv6 = s } } } } - // 3. Validate at least one IP found + // 4. Validate at least one IP found if ipv4 == "" && ipv6 == "" { err = fmt.Errorf("could not determine IPv4 or IPv6 address from config, env, or external service") }