diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4ad62c1..ddf7bb0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,6 +38,7 @@ jobs: libtest-tcp-perl \ libtest-warnings-perl \ liburi-perl \ + net-tools \ make \ ; - uses: actions/checkout@v2 @@ -117,6 +118,7 @@ jobs: perl-Test-MockModule \ perl-Test-TCP \ perl-Test-Warnings \ + net-tools \ ; - name: autogen run: ./autogen @@ -142,6 +144,7 @@ jobs: perl-HTTP-Daemon \ perl-IO-Socket-INET6 \ perl-core \ + iproute \ ; - name: autogen run: ./autogen diff --git a/ddclient.in b/ddclient.in index 25c1bd1..000dc73 100755 --- a/ddclient.in +++ b/ddclient.in @@ -2705,12 +2705,69 @@ my $regex_ipv6_ula = qr{ $regex_ipv6 # And is a valid IPv6 address }xi; +###################################################################### +## get_default_interface finds the default network interface based on +## the IP routing table on the system. We validate that the interface +## found is likely to have global routing (e.g. is not LOOPBACK). +## Returns undef if no global scope interface can be found for IP version. +###################################################################### +sub get_default_interface { + my $ipver = int(shift // 4); ## Defaults to IPv4 if not specified + my $ipstr = ($ipver == 6) ? 'inet6' : 'inet'; + my $reply = shift // ''; ## Pass in data for unit testing purposes only + my $cmd = "test"; + + return undef if (($ipver != 4) && ($ipver != 6)); + + if (!$reply) { ## skip if test data passed in. + ## Best option is the ip command from iproute2 package + $cmd = "ip -$ipver -o route list match default"; $reply = qx{ $cmd 2>/dev/null }; + ## Fallback is the netstat command. This is only option on MacOS. + if ($?) { $cmd = "netstat -rn -$ipver"; $reply = qx{ $cmd 2>/dev/null }; } # Linux, FreeBSD + if ($?) { $cmd = "netstat -rn -f $ipstr"; $reply = qx{ $cmd 2>/dev/null }; } # MacOS + if ($?) { $cmd = "netstat -rn"; $reply = qx{ $cmd 2>/dev/null }; } # Busybox + if ($?) { $cmd = "missing ip or netstat command"; + failed("Unable to obtain default route information -- %s", $cmd) + } + } + debug("Reply from '%s' :\n------\n%s------", $cmd, $reply); + + # Check we have IPv6 address in case we got routing table from non-specific cmd above + return undef if (($ipver == 6) && !extract_ipv6($reply)); + # Filter down to just the default interfaces + my @list = split(/\n/, $reply); + @list = grep(/^default|^(?:0\.){3}0|^::\/0/, @list); # Select 'default' or '0.0.0.0' or '::/0' + return undef if (scalar(@list) == 0); + debug("Default routes found for IPv%s :\n%s", $ipver, join("\n",@list)); + + # now check each interface to make sure it is global (not loopback). + foreach my $line (@list) { + ## Interface will be after "dev" or the last word in the line. Must accept blank spaces + ## at the end. Interface name may not have any whitespace or forward slash. + $line =~ /\bdev\b\s*\K[^\s\/]+|\b[^\s\/]+(?=[\s\/]*$)/; + my $interface = $&; + ## If test data was passed in skip following tests + if ($cmd ne "test") { + ## We do not want the loopback interface or anything interface without global scope + $cmd = "ip -$ipver -o addr show dev $interface scope global"; $reply = qx{$cmd 2>/dev/null}; + if ($?) { $cmd = "ifconfig $interface"; $reply = qx{$cmd 2>/dev/null}; } + if ($?) { $cmd = "missing ip or ifconfig command"; + failed("Unable to obtain information for '%s' -- %s", $interface, $cmd); + } + debug("Reply from '%s' :\n------\n%s------", $cmd, $reply); + } + ## Has global scope, is not LOOPBACK + return($interface) if (($reply) && ($reply !~ /\bLOOPBACK\b/)); + } + return undef; +} + ###################################################################### ## get_ip_from_interface() finds an IPv4 or IPv6 address from a network ## interface. Defaults to IPv4 unless '6' passed as 2nd parameter. ###################################################################### sub get_ip_from_interface { - my $interface = shift; + my $interface = shift // "default"; my $ipver = int(shift // 4); ## Defaults to IPv4 if not specified my $scope = lc(shift // "gua"); ## "gua" or "ula" my $reply = shift // ''; ## Pass in data for unit testing purposes only @@ -2723,7 +2780,8 @@ sub get_ip_from_interface { return undef; } - if ($reply eq '') { ## skip if test data passed in. + if ((lc($interface) eq "default") && (!$reply)) { ## skip if test data passed in. + $interface = get_default_interface($ipver); return undef if !defined($interface); } diff --git a/t/get_ip_from_if.pl b/t/get_ip_from_if.pl index d4a0c4b..6f08e5d 100644 --- a/t/get_ip_from_if.pl +++ b/t/get_ip_from_if.pl @@ -8,6 +8,19 @@ eval { require 'ddclient'; } or BAIL_OUT($@); #STDOUT->autoflush(1); #$ddclient::globals{'debug'} = 1; +subtest "get_default_interface tests" => sub { + for my $sample (@ddclient::t::routing_samples) { + if (defined($sample->{want_ipv4_if})) { + my $interface = ddclient::get_default_interface(4, $sample->{text}); + is($interface, $sample->{want_ipv4_if}, $sample->{name}); + } + if (defined($sample->{want_ipv6_if})) { + my $interface = ddclient::get_default_interface(6, $sample->{text}); + is($interface, $sample->{want_ipv6_if}, $sample->{name}); + } + } +}; + subtest "get_ip_from_interface tests" => sub { for my $sample (@ddclient::t::interface_samples) { # interface name is undef as we are passing in test data @@ -26,4 +39,25 @@ subtest "get_ip_from_interface tests" => sub { } }; +subtest "Get default interface and IP for test system" => sub { + my $interface = ddclient::get_default_interface(4); + if ($interface) { + isnt($interface, "lo", "Check for loopback 'lo'"); + isnt($interface, "lo0", "Check for loopback 'lo0'"); + my $ip1 = ddclient::get_ip_from_interface("default", 4); + my $ip2 = ddclient::get_ip_from_interface($interface, 4); + is($ip1, $ip2, "Check IPv4 from default interface"); + ok(ddclient::is_ipv4($ip1), "Valid IPv4 from get_ip_from_interface($interface)"); + } + $interface = ddclient::get_default_interface(6); + if ($interface) { + isnt($interface, "lo", "Check for loopback 'lo'"); + isnt($interface, "lo0", "Check for loopback 'lo0'"); + my $ip1 = ddclient::get_ip_from_interface("default", 6); + my $ip2 = ddclient::get_ip_from_interface($interface, 6); + is($ip1, $ip2, "Check IPv6 from default interface"); + ok(ddclient::is_ipv6($ip1), "Valid IPv6 from get_ip_from_interface($interface)"); + } +}; + done_testing(); diff --git a/t/lib/ddclient/t.pm b/t/lib/ddclient/t.pm index 2899e4a..c546b9c 100644 --- a/t/lib/ddclient/t.pm +++ b/t/lib/ddclient/t.pm @@ -301,3 +301,262 @@ EOF want_ipv4_from_if => "198.51.157.237", }, ); + +###################################################################### +## Outputs from ip route and netstat commands to find default route (and therefore interface) +## Samples from Ubuntu 20.04, RHEL8, Buildroot, Busybox, MacOS 10.15, FreeBSD +## NOTE: Any tabs/whitespace at start or end of lines are intentional to match real life data. +###################################################################### +our @routing_samples = ( + { name => "ip -4 -o route list match default (most linux)", + text => < "ens33", + }, + { name => "ip -4 -o route list match default (most linux)", + text => < "ens33", + }, + { name => "ip -4 -o route list match default (buildroot)", + text => < "eth0", + }, + { name => "ip -6 -o route list match default (buildroot)", + text => < "eth0", + }, + { name => "netstat -rn -4 (most linux)", + text => < "ens33", + }, + { name => "netstat -rn -4 (FreeBSD)", + text => < "em0", + }, + { name => "netstat -rn -6 (FreeBSD)", + text => < "em0", + }, + { name => "netstat -rn -6 (most linux)", + text => < "ens33", + }, + { name => "netstat -rn -f inet (MacOS)", + text => < "en0", + }, + { name => "netstat -rn -f inet6 (MacOS)", + text => < "en0", + }, +);