diff --git a/cmd/ddns-updater/main.go b/cmd/ddns-updater/main.go index a472c35bb..c3df5af66 100644 --- a/cmd/ddns-updater/main.go +++ b/cmd/ddns-updater/main.go @@ -187,8 +187,11 @@ func _main(ctx context.Context, reader *reader.Reader, args []string, logger log Enabled: *config.PubIP.DNSEnabled, Options: config.PubIP.ToDNSPOptions(), } + privateIPSettings := publicip.PrivateIPSettings{ + Enabled: *config.PubIP.PrivateIPEnabled, + } - ipGetter, err := publicip.NewFetcher(dnsSettings, httpSettings) + ipGetter, err := publicip.NewFetcher(dnsSettings, httpSettings, privateIPSettings) if err != nil { return err } diff --git a/internal/config/pubip.go b/internal/config/pubip.go index 4ca81489a..3e2999c67 100644 --- a/internal/config/pubip.go +++ b/internal/config/pubip.go @@ -10,6 +10,7 @@ import ( "github.com/qdm12/ddns-updater/pkg/publicip/dns" "github.com/qdm12/ddns-updater/pkg/publicip/http" "github.com/qdm12/ddns-updater/pkg/publicip/ipversion" + "github.com/qdm12/ddns-updater/pkg/publicip/privateip" "github.com/qdm12/gosettings" "github.com/qdm12/gosettings/reader" "github.com/qdm12/gosettings/validate" @@ -24,6 +25,7 @@ type PubIP struct { DNSEnabled *bool DNSProviders []string DNSTimeout time.Duration + PrivateIPEnabled *bool } func (p *PubIP) setDefaults() { @@ -35,6 +37,7 @@ func (p *PubIP) setDefaults() { p.DNSProviders = gosettings.DefaultSlice(p.DNSProviders, []string{all}) const defaultDNSTimeout = 3 * time.Second p.DNSTimeout = gosettings.DefaultComparable(p.DNSTimeout, defaultDNSTimeout) + p.PrivateIPEnabled = gosettings.DefaultPointer(p.PrivateIPEnabled, true) } func (p PubIP) Validate() (err error) { @@ -95,6 +98,8 @@ func (p *PubIP) toLinesNode() (node *gotree.Node) { } } + node.Appendf("Private IP enabled: %s", gosettings.BoolToYesNo(p.PrivateIPEnabled)) + return node } @@ -110,6 +115,13 @@ func (p *PubIP) ToHTTPOptions() (options []http.Option) { } } +// ToPrivateIPSettings assumes the settings have been validated. +func (p *PubIP) ToPrivateIPSettings() privateip.Settings { + return privateip.Settings{ + Enabled: *p.PrivateIPEnabled, + } +} + func stringsToHTTPProviders(providers []string, ipVersion ipversion.IPVersion) ( updatedProviders []http.Provider) { updatedProvidersSet := make(map[string]struct{}, len(providers)) @@ -234,11 +246,13 @@ func validateHTTPIPProviders(providerStrings []string, } func (p *PubIP) read(r *reader.Reader, warner Warner) (err error) { - p.HTTPEnabled, p.DNSEnabled, err = getFetchers(r) + p.HTTPEnabled, p.DNSEnabled, p.PrivateIPEnabled, err = getFetchers(r) if err != nil { return err } + p.PrivateIPEnabled = gosettings.DefaultPointer(p.PrivateIPEnabled, false) + p.HTTPIPProviders = r.CSV("PUBLICIP_HTTP_PROVIDERS", reader.RetroKeys("IP_METHOD")) p.HTTPIPv4Providers = r.CSV("PUBLICIPV4_HTTP_PROVIDERS", @@ -297,32 +311,35 @@ func (p *PubIP) read(r *reader.Reader, warner Warner) (err error) { var ErrFetcherNotValid = errors.New("fetcher is not valid") -func getFetchers(reader *reader.Reader) (http, dns *bool, err error) { +func getFetchers(reader *reader.Reader) (http, dns, privateIP *bool, err error) { // TODO change to use reader.BoolPtr with retro-compatibility s := reader.String("PUBLICIP_FETCHERS") if s == "" { - return nil, nil, nil + return nil, nil, nil, nil } - http, dns = new(bool), new(bool) + http, dns, privateIP = new(bool), new(bool), new(bool) fields := strings.Split(s, ",") for i, field := range fields { switch strings.ToLower(field) { case "all": *http = true *dns = true + *privateIP = true case "http": *http = true case "dns": *dns = true + case "privateip": + *privateIP = true default: - return nil, nil, fmt.Errorf( + return nil, nil, nil, fmt.Errorf( "%w: %q at position %d of %d", ErrFetcherNotValid, field, i+1, len(fields)) } } - return http, dns, nil + return http, dns, privateIP, nil } func handleRetroProvider(provider string) (updatedProvider string) { diff --git a/internal/config/settings_test.go b/internal/config/settings_test.go index 59f083023..645a50be2 100644 --- a/internal/config/settings_test.go +++ b/internal/config/settings_test.go @@ -30,8 +30,9 @@ func Test_Settings_String(t *testing.T) { | | └── all | ├── DNS enabled: yes | ├── DNS timeout: 3s -| └── DNS over TLS providers -| └── all +| ├── DNS over TLS providers +| | └── all +| └── Private IP enabled: yes ├── Resolver: use Go default resolver ├── Server | ├── Listening address: :8000 diff --git a/pkg/publicip/privateip/options.go b/pkg/publicip/privateip/options.go new file mode 100644 index 000000000..86e492826 --- /dev/null +++ b/pkg/publicip/privateip/options.go @@ -0,0 +1,22 @@ +package privateip + +import ( + "errors" +) + +type Options struct{} + +var ( + ErrInvalidOption = errors.New("invalid option for private IP retrieval") +) + +// NewOptions returns default options for the private IP provider +func NewOptions() *Options { + return &Options{} +} + +// Validate checks the options (for private IP, there might be no specific options to validate) +func (o *Options) Validate() error { + // No specific options to validate for private IP retrieval + return nil +} diff --git a/pkg/publicip/privateip/options_test.go b/pkg/publicip/privateip/options_test.go new file mode 100644 index 000000000..858805e6a --- /dev/null +++ b/pkg/publicip/privateip/options_test.go @@ -0,0 +1,33 @@ +package privateip + +import ( + "testing" +) + +func TestNewOptions(t *testing.T) { + t.Parallel() + + opts := NewOptions() + if opts == nil { + t.Fatalf("NewOptions() returned nil, expected non-nil *Options") + } +} + +func TestOptions_Validate(t *testing.T) { + t.Parallel() + + opts := NewOptions() + err := opts.Validate() + if err != nil { + t.Errorf("Options.Validate() returned error: %v, expected nil", err) + } +} + +func TestErrInvalidOption(t *testing.T) { + t.Parallel() + + expectedMessage := "invalid option for private IP retrieval" + if ErrInvalidOption.Error() != expectedMessage { + t.Errorf("ErrInvalidOption message = %q, want %q", ErrInvalidOption.Error(), expectedMessage) + } +} diff --git a/pkg/publicip/privateip/privateip.go b/pkg/publicip/privateip/privateip.go new file mode 100644 index 000000000..c055ce205 --- /dev/null +++ b/pkg/publicip/privateip/privateip.go @@ -0,0 +1,129 @@ +package privateip + +import ( + "context" + "fmt" + "net" + "net/netip" +) + +// Settings structure for configuring the Private Fetcher. +type Settings struct { + Enabled bool +} + +// InterfaceRetriever defines methods to retrieve network interfaces and their addresses. +type InterfaceRetriever interface { + Interfaces() ([]net.Interface, error) + Addrs(iface net.Interface) ([]net.Addr, error) +} + +// RealInterfaceRetriever is the production implementation of InterfaceRetriever. +type RealInterfaceRetriever struct{} + +// Interfaces retrieves the list of network interfaces. +func (rir RealInterfaceRetriever) Interfaces() ([]net.Interface, error) { + return net.Interfaces() +} + +// Addrs retrieves the addresses for a given network interface. +func (rir RealInterfaceRetriever) Addrs(iface net.Interface) ([]net.Addr, error) { + return iface.Addrs() +} + +// Fetcher struct to represent the private IP fetcher. +type Fetcher struct { + settings Settings + interfaceRetriever InterfaceRetriever +} + +// New creates a new instance of the Private Fetcher with dependency injection. +func New(settings Settings, retriever InterfaceRetriever) (*Fetcher, error) { + if !settings.Enabled { + return nil, fmt.Errorf("private IP fetcher is disabled") + } + + if retriever == nil { + return nil, fmt.Errorf("interface retriever cannot be nil") + } + + return &Fetcher{ + settings: settings, + interfaceRetriever: retriever, + }, nil +} + +// IP fetches the private IP address of the machine. +func (f *Fetcher) IP(ctx context.Context) (netip.Addr, error) { + interfaces, err := f.interfaceRetriever.Interfaces() + if err != nil { + return netip.Addr{}, err + } + + for _, iface := range interfaces { + if iface.Flags&net.FlagUp == 0 || iface.Flags&net.FlagLoopback != 0 { + continue + } + + addrs, err := f.interfaceRetriever.Addrs(iface) + if err != nil { + return netip.Addr{}, err + } + + for _, addr := range addrs { + switch v := addr.(type) { + case *net.IPNet: + if isPrivateIP(v.IP) { + privateAddr, err := netip.ParseAddr(v.IP.String()) + if err != nil { + return netip.Addr{}, err + } + return privateAddr, nil + } + } + } + } + + return netip.Addr{}, fmt.Errorf("no private IP address found") +} + +// IP4 fetches the IPv4 address of the machine. +func (f *Fetcher) IP4(ctx context.Context) (netip.Addr, error) { + ip, err := f.IP(ctx) + if err != nil { + return netip.Addr{}, err + } + if ip.Is4() { + return ip, nil + } + return netip.Addr{}, fmt.Errorf("no IPv4 address found") +} + +// IP6 fetches the IPv6 address of the machine. +func (f *Fetcher) IP6(ctx context.Context) (netip.Addr, error) { + ip, err := f.IP(ctx) + if err != nil { + return netip.Addr{}, err + } + if ip.Is6() { + return ip, nil + } + return netip.Addr{}, fmt.Errorf("no IPv6 address found") +} + +// isPrivateIP checks if an IP is from a private range. +func isPrivateIP(ip net.IP) bool { + privateBlocks := []string{ + "10.0.0.0/8", + "172.16.0.0/12", + "192.168.0.0/16", + } + + for _, block := range privateBlocks { + _, cidr, _ := net.ParseCIDR(block) + if cidr.Contains(ip) { + return true + } + } + return false +} diff --git a/pkg/publicip/privateip/privateip_test.go b/pkg/publicip/privateip/privateip_test.go new file mode 100644 index 000000000..b2a7298ef --- /dev/null +++ b/pkg/publicip/privateip/privateip_test.go @@ -0,0 +1,301 @@ +package privateip + +import ( + "context" + "errors" + "net" + "testing" +) + +// MockInterfaceRetriever is a mock implementation of InterfaceRetriever for testing. +type MockInterfaceRetriever struct { + InterfacesFunc func() ([]net.Interface, error) + AddrsFunc func(net.Interface) ([]net.Addr, error) +} + +// Interfaces mocks the retrieval of network interfaces. +func (m *MockInterfaceRetriever) Interfaces() ([]net.Interface, error) { + return m.InterfacesFunc() +} + +// Addrs mocks the retrieval of addresses for a given network interface. +func (m *MockInterfaceRetriever) Addrs(iface net.Interface) ([]net.Addr, error) { + return m.AddrsFunc(iface) +} + +// TestFetcher_Success simulates the scenario where a private IP address is found. +func TestFetcher_Success(t *testing.T) { + t.Parallel() + + privateIP := net.ParseIP("192.168.1.10").To4() + if privateIP == nil { + t.Fatalf("Failed to parse private IP") + } + + mockRetriever := &MockInterfaceRetriever{ + InterfacesFunc: func() ([]net.Interface, error) { + return []net.Interface{ + { + Flags: net.FlagUp, + Name: "eth0", + }, + }, nil + }, + AddrsFunc: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{ + IP: privateIP, + Mask: net.CIDRMask(24, 32), + }, + }, nil + }, + } + + settings := Settings{Enabled: true} + fetcher, err := New(settings, mockRetriever) + if err != nil { + t.Fatalf("Failed to create Fetcher: %v", err) + } + + ip, err := fetcher.IP(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if ip.String() != "192.168.1.10" { + t.Errorf("expected IP 192.168.1.10, got: %v", ip) + } +} + +// TestFetcher_NoPrivateIP simulates the scenario where no private IP address is found. +func TestFetcher_NoPrivateIP(t *testing.T) { + t.Parallel() + + publicIP := net.ParseIP("8.8.8.8").To4() + if publicIP == nil { + t.Fatalf("Failed to parse public IP") + } + + mockRetriever := &MockInterfaceRetriever{ + InterfacesFunc: func() ([]net.Interface, error) { + return []net.Interface{ + { + Flags: net.FlagUp, + Name: "eth0", + }, + }, nil + }, + AddrsFunc: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{ + IP: publicIP, + Mask: net.CIDRMask(24, 32), + }, + }, nil + }, + } + + settings := Settings{Enabled: true} + fetcher, err := New(settings, mockRetriever) + if err != nil { + t.Fatalf("Failed to create Fetcher: %v", err) + } + + _, err = fetcher.IP(context.Background()) + if err == nil { + t.Fatalf("expected error, got none") + } + expectedErr := "no private IP address found" + if err.Error() != expectedErr { + t.Errorf("expected error '%s', got: %v", expectedErr, err) + } +} + +// TestFetcher_ErrorRetrievingInterfaces simulates an error when retrieving interfaces. +func TestFetcher_ErrorRetrievingInterfaces(t *testing.T) { + t.Parallel() + + mockRetriever := &MockInterfaceRetriever{ + InterfacesFunc: func() ([]net.Interface, error) { + return nil, errors.New("mock error retrieving interfaces") + }, + AddrsFunc: func(_ net.Interface) ([]net.Addr, error) { + return nil, nil + }, + } + + settings := Settings{Enabled: true} + fetcher, err := New(settings, mockRetriever) + if err != nil { + t.Fatalf("Failed to create Fetcher: %v", err) + } + + _, err = fetcher.IP(context.Background()) + if err == nil { + t.Fatalf("expected error, got none") + } + expectedErr := "mock error retrieving interfaces" + if err.Error() != expectedErr { + t.Errorf("expected error '%s', got: %v", expectedErr, err) + } +} + +// TestFetcher_ErrorRetrievingAddrs simulates an error when retrieving addresses. +func TestFetcher_ErrorRetrievingAddrs(t *testing.T) { + t.Parallel() + + mockRetriever := &MockInterfaceRetriever{ + InterfacesFunc: func() ([]net.Interface, error) { + return []net.Interface{ + { + Flags: net.FlagUp, + Name: "eth0", + }, + }, nil + }, + AddrsFunc: func(_ net.Interface) ([]net.Addr, error) { + return nil, errors.New("mock error retrieving addresses") + }, + } + + settings := Settings{Enabled: true} + fetcher, err := New(settings, mockRetriever) + if err != nil { + t.Fatalf("Failed to create Fetcher: %v", err) + } + + _, err = fetcher.IP(context.Background()) + if err == nil { + t.Fatalf("expected error, got none") + } + expectedErr := "mock error retrieving addresses" + if err.Error() != expectedErr { + t.Errorf("expected error '%s', got: %v", expectedErr, err) + } +} + +// TestFetcher_MultipleAddresses tests multiple addresses with at least one private IP. +func TestFetcher_MultipleAddresses(t *testing.T) { + t.Parallel() + + privateIP := net.ParseIP("10.0.0.5").To4() + publicIP := net.ParseIP("8.8.8.8").To4() + if privateIP == nil || publicIP == nil { + t.Fatalf("Failed to parse IPs") + } + + mockRetriever := &MockInterfaceRetriever{ + InterfacesFunc: func() ([]net.Interface, error) { + return []net.Interface{ + { + Flags: net.FlagUp, + Name: "eth0", + }, + }, nil + }, + AddrsFunc: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{ + IP: publicIP, + Mask: net.CIDRMask(24, 32), + }, + &net.IPNet{ + IP: privateIP, + Mask: net.CIDRMask(24, 32), + }, + }, nil + }, + } + + settings := Settings{Enabled: true} + fetcher, err := New(settings, mockRetriever) + if err != nil { + t.Fatalf("Failed to create Fetcher: %v", err) + } + + ip, err := fetcher.IP(context.Background()) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + if ip.String() != "10.0.0.5" { + t.Errorf("expected IP 10.0.0.5, got: %v", ip) + } +} + +// TestFetcher_InvalidIP tests an invalid IP address in the interface addresses. +func TestFetcher_InvalidIP(t *testing.T) { + t.Parallel() + + invalidIP := []byte{0xFF, 0xFF, 0xFF, 0xFF} // Invalid IP + + mockRetriever := &MockInterfaceRetriever{ + InterfacesFunc: func() ([]net.Interface, error) { + return []net.Interface{ + { + Flags: net.FlagUp, + Name: "eth0", + }, + }, nil + }, + AddrsFunc: func(_ net.Interface) ([]net.Addr, error) { + return []net.Addr{ + &net.IPNet{ + IP: invalidIP, + Mask: net.CIDRMask(24, 32), + }, + }, nil + }, + } + + settings := Settings{Enabled: true} + fetcher, err := New(settings, mockRetriever) + if err != nil { + t.Fatalf("Failed to create Fetcher: %v", err) + } + + ip, err := fetcher.IP(context.Background()) + if err == nil { + t.Fatalf("expected error, got: %v", err) + } + expectedErr := "no private IP address found" + if err.Error() != expectedErr { + t.Errorf("expected error '%s', got: %v", expectedErr, err) + } + + if ip.IsValid() { + t.Errorf("expected an invalid IP address, got: %v", ip) + } +} + +// TestFetcher_Disabled tests the scenario where the Fetcher is disabled. +func TestFetcher_Disabled(t *testing.T) { + t.Parallel() + + settings := Settings{Enabled: false} + + mockRetriever := &MockInterfaceRetriever{} // This won't be used since Fetcher is disabled. + + _, err := New(settings, mockRetriever) + if err == nil { + t.Fatalf("expected error, got none") + } + expectedErr := "private IP fetcher is disabled" + if err.Error() != expectedErr { + t.Errorf("expected error '%s', got: %v", expectedErr, err) + } +} + +// TestFetcher_NilRetriever tests the scenario where a nil retriever is provided. +func TestFetcher_NilRetriever(t *testing.T) { + t.Parallel() + + settings := Settings{Enabled: true} + + _, err := New(settings, nil) + if err == nil { + t.Fatalf("expected error, got none") + } + expectedErr := "interface retriever cannot be nil" + if err.Error() != expectedErr { + t.Errorf("expected error '%s', got: %v", expectedErr, err) + } +} diff --git a/pkg/publicip/publicip.go b/pkg/publicip/publicip.go index 5edc5473b..d6060f8b6 100644 --- a/pkg/publicip/publicip.go +++ b/pkg/publicip/publicip.go @@ -7,6 +7,7 @@ import ( "github.com/qdm12/ddns-updater/pkg/publicip/dns" "github.com/qdm12/ddns-updater/pkg/publicip/http" + "github.com/qdm12/ddns-updater/pkg/publicip/privateip" ) type ipFetcher interface { @@ -24,10 +25,14 @@ type Fetcher struct { var ErrNoFetchTypeSpecified = errors.New("at least one fetcher type must be specified") -func NewFetcher(dnsSettings DNSSettings, httpSettings HTTPSettings) (f *Fetcher, err error) { +func NewFetcher( + dnsSettings DNSSettings, + httpSettings HTTPSettings, + privateIPSettings PrivateIPSettings) (f *Fetcher, err error) { settings := settings{ - dns: dnsSettings, - http: httpSettings, + dns: dnsSettings, + http: httpSettings, + privateIP: privateIPSettings, } fetcher := &Fetcher{ @@ -51,6 +56,18 @@ func NewFetcher(dnsSettings DNSSettings, httpSettings HTTPSettings) (f *Fetcher, fetcher.fetchers = append(fetcher.fetchers, subFetcher) } + if settings.privateIP.Enabled { + // Instantiate the InterfaceRetriever + retriever := privateip.RealInterfaceRetriever{} + + // Pass both Settings and the retriever to privateip.New + subFetcher, err := privateip.New(privateip.Settings{Enabled: true}, retriever) + if err != nil { + return nil, err + } + fetcher.fetchers = append(fetcher.fetchers, subFetcher) + } + if len(fetcher.fetchers) == 0 { return nil, ErrNoFetchTypeSpecified } diff --git a/pkg/publicip/settings.go b/pkg/publicip/settings.go index abe8555ab..f0201cc78 100644 --- a/pkg/publicip/settings.go +++ b/pkg/publicip/settings.go @@ -9,8 +9,9 @@ import ( type settings struct { // If both dns and http are enabled it will cycle between both of them. - dns DNSSettings - http HTTPSettings + dns DNSSettings + http HTTPSettings + privateIP PrivateIPSettings } type DNSSettings struct { @@ -23,3 +24,7 @@ type HTTPSettings struct { Client *http.Client Options []iphttp.Option } + +type PrivateIPSettings struct { + Enabled bool +}