Add dhcpcd network backend for Debian and Raspberry Pi OS

This PR adds dhcpcd backend support for Debian and Raspberry Pi OS network configuration. It detects dhcpcd only as a final fallback after Netplan, NetworkManager, and ifupdown, preventing Webmin from incorrectly falling back to `/etc/network/interfaces` on dhcpcd-managed systems.

The new backend reads and writes `/etc/dhcpcd.conf`, including DHCP and static IPv4/IPv6 configuration, gateways, static routes, DNS servers, search domains, MTU, and virtual IPv4 aliases. It also supports implicit DHCP-managed interfaces for default dhcpcd setups with no explicit interface blocks, and handles `allowinterfaces` / `denyinterfaces` behavior.

This PR also fixes apply/delete flows for dhcpcd-managed interfaces and virtual aliases, avoids rewriting generated `/etc/resolv.conf`, preserves spacing/comments in touched hosts and nsswitch files, and tightens Active Now handling so virtual aliases are treated as IP addresses rather than independent links.

https://github.com/webmin/webmin/issues/1607
This commit is contained in:
Ilia Ross
2026-06-20 01:57:50 +02:00
parent 29c14acf98
commit 7ebe3f7dfa
12 changed files with 1673 additions and 46 deletions

View File

@@ -16,9 +16,15 @@ foreach $d (@d) {
($a) = grep { $_->{'fullname'} eq $d } @acts;
$a || &error($text{'daifcs_egone'});
&can_iface($a) || &error($text{'ifcs_ecannot_this'});
&deactivate_interface($a);
if (defined(&delete_active_interface)) {
# Config-driven backends may need to remove persistent state too.
$err = &delete_active_interface($a);
$err && &error("<pre>$err</pre>");
}
else {
&deactivate_interface($a);
}
}
&webmin_log("delete", "aifcs", scalar(@d));
&redirect("list_ifcs.cgi?mode=active");

View File

@@ -14,27 +14,40 @@ foreach $d (reverse(@d)) {
($b) = grep { $_->{'fullname'} eq $d } @boot;
$b || &error($text{'daifcs_egone'});
&can_iface($b) || &error($text{'ifcs_ecannot_this'});
$act = undef;
if ($in{'deleteapply'}) {
($act) = grep { $_->{'fullname'} eq $b->{'fullname'} } @active;
if (!$act && $b->{'virtual'} ne '' && $b->{'address'}) {
# ip(8) may renumber unlabelled secondary addresses.
($act) = grep { $_->{'virtual'} ne '' &&
$_->{'name'} eq $b->{'name'} &&
$_->{'address'} eq $b->{'address'} } @active;
}
}
if ($in{'apply'}) {
# Make this interface active
&activate_interface($b);
if (defined(&apply_interface)) {
$err = &apply_interface($b);
$err && &error("<pre>$err</pre>");
}
else {
&activate_interface($b);
}
}
else {
# Deleting
if ($in{'deleteapply'}) {
# De-activate first
($act) = grep { $_->{'fullname'} eq $b->{'fullname'} }
@active;
if ($act) {
if (defined(&unapply_interface)) {
$err = &unapply_interface($act);
$err && &error("<pre>$err</pre>");
}
else {
&deactivate_interface($act);
if(&iface_type($b->{'name'}) eq 'Bonded'){
if (($gconfig{'os_type'} eq 'debian-linux') && ($gconfig{'os_version'} >= 5)) {}
else {&unload_module($b->{'name'});}
}
if ($in{'deleteapply'} && $act &&
!defined(&unapply_interface_after_delete)) {
# De-activate first for legacy immediate-action backends.
if (defined(&unapply_interface)) {
$err = &unapply_interface($act);
$err && &error("<pre>$err</pre>");
}
else {
&deactivate_interface($act);
if(&iface_type($b->{'name'}) eq 'Bonded'){
if (($gconfig{'os_type'} eq 'debian-linux') && ($gconfig{'os_version'} >= 5)) {}
else {&unload_module($b->{'name'});}
}
}
}
@@ -45,9 +58,14 @@ foreach $d (reverse(@d)) {
defined(&delete_module_def)){
&delete_module_def($b->{'name'});
}
if ($in{'deleteapply'} && $act &&
defined(&unapply_interface_after_delete)) {
# Config-driven backends apply removals after deleting config.
$err = &unapply_interface_after_delete($act, $b);
$err && &error("<pre>$err</pre>");
}
}
}
&webmin_log($in{'apply'} ? "apply" : "delete", "bifcs", scalar(@d));
&redirect("list_ifcs.cgi?mode=boot");

1004
net/dhcpcd-lib.pl Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -128,16 +128,21 @@ else {
print &ui_table_row($text{'ifcs_mtu'}, $mtufield);
# Current status
if (!$access{'up'}) {
if (($in{'new'} && $in{'virtual'}) || ($a && $a->{'virtual'} ne "")) {
# Virtual aliases are IP addresses, not links with independent status.
print &ui_hidden("up", 1);
}
elsif (!$access{'up'}) {
# Cannot edit
$upfield = !$a ? $text{'ifcs_up'} :
$a->{'up'} ? $text{'ifcs_up'} : $text{'ifcs_down'};
print &ui_table_row($text{'ifcs_status'}, $upfield);
}
else {
$upfield = &ui_radio("up", $in{'new'} || $a->{'up'} ? 1 : 0,
[ [ 1, $text{'ifcs_up'} ], [ 0, $text{'ifcs_down'} ] ]);
print &ui_table_row($text{'ifcs_status'}, $upfield);
}
print &ui_table_row($text{'ifcs_status'}, $upfield);
# Hardware address, if non-virtual
if ((!$a && $in{'virtual'} eq "") ||
@@ -192,4 +197,3 @@ else {
print &ui_form_end(\@buts);
&ui_print_footer("list_ifcs.cgi?mode=active", $text{'ifcs_return'});

View File

@@ -10,6 +10,7 @@ index_apply2=Apply Selected Interfaces
index_vmin=Virtualmin
index_mode=Network config type: $1
index_mode_netplan=Netplan
index_mode_dhcpcd=dhcpcd
index_mode_cygwin=Cygwin
index_mode_freebsd=FreeBSD
index_mode_macos=MacOS
@@ -79,6 +80,7 @@ aifc_err2=Failed to save interface
aifc_evirt=Missing or invalid virtual interface number
aifc_evirtmin=Virtual interface number must be at least $1
aifc_evirtdup=The virtual interface $1 already exists
aifc_evirtdown=Virtual interfaces cannot be created with down status
aifc_edup=The interface $1 already exists
aifc_ename=Missing or invalid interface name
aifc_eip='$1' is not a valid IP address

View File

@@ -252,6 +252,12 @@ if(($a->{'vlan'} == 1) && !(($gconfig{'os_type'} eq 'debian-linux') && ($gconfig
if ($?) { &error($vonconfigout); }
}
if (&has_command("ip") && $a->{'virtual'} ne '' && !$a->{'up'}) {
# Linux virtual aliases are addresses, not independent links.
&deactivate_interface($old) if ($old && $old->{'address'});
return;
}
if (!&has_command("ifconfig") && &has_command("ip")) {
# For a real interface, activate or de-activate the link
if ($a->{'virtual'} eq '' && $a->{'up'} && (!$old || !$old->{'up'})) {
@@ -942,7 +948,8 @@ close(SWITCH);
&open_tempfile(SWITCH, ">/etc/nsswitch.conf");
foreach (@switch) {
if (/^\s*hosts:\s+/) {
&print_tempfile(SWITCH, "hosts:\t$conf->{'order'}\n");
&print_tempfile(SWITCH,
&linux_nsswitch_hosts_line($_, $conf->{'order'}));
}
else {
&print_tempfile(SWITCH, $_);
@@ -1007,4 +1014,20 @@ else {
}
}
# linux_nsswitch_hosts_line(line, order)
# Returns an updated nsswitch hosts line preserving existing spacing
sub linux_nsswitch_hosts_line
{
my ($line, $order) = @_;
$line =~ s/\r?\n$//;
my $comment = "";
if ($line =~ s/(\s+#.*)$//) {
# Keep inline comments while replacing only the lookup order.
$comment = $1;
}
return $1.$2.$order.$comment."\n"
if ($line =~ /^(\s*hosts:)(\s+)\S/);
return "hosts:\t$order$comment\n";
}
1;

View File

@@ -1,6 +1,8 @@
# net-detect.pl
# Helper functions for choosing the network config backend
# net_has_network_manager_config([connections-directory])
# Returns true if NetworkManager connection profiles exist
sub net_has_network_manager_config
{
my ($dir) = @_;
@@ -9,6 +11,8 @@ my @files = glob("$dir/*.nmconnection");
return -d $dir && scalar(@files);
}
# net_has_netplan_config([netplan-directory])
# Returns true if Netplan is installed and its config directory exists
sub net_has_netplan_config
{
my ($dir) = @_;
@@ -17,15 +21,128 @@ return &has_command("netplan") &&
-d $dir;
}
# net_has_ifupdown_config([interfaces-file], [&seen-files])
# Returns true if ifupdown has any non-loopback iface stanzas
sub net_has_ifupdown_config
{
my ($file, $done) = @_;
$file ||= "/etc/network/interfaces";
$done ||= { };
# Avoid loops from recursive source/source-directory includes.
return 0 if ($done->{$file}++);
open(my $fh, "<", $file) || return 0;
while(my $line = <$fh>) {
$line =~ s/#.*$//;
# Loopback alone does not mean ifupdown owns real interfaces.
if ($line =~ /^\s*iface\s+(\S+)\s+\S+\s+\S+/ && $1 ne "lo") {
close($fh);
return 1;
}
# Debian supports including all simple filenames from a directory.
if ($line =~ /^\s*source-directory\s+(\S+)/) {
my $dir = $1;
if (opendir(my $dh, $dir)) {
foreach my $name (grep { /^[A-Za-z0-9_-]+$/ } readdir($dh)) {
if (&net_has_ifupdown_config("$dir/$name", $done)) {
closedir($dh);
close($fh);
return 1;
}
}
closedir($dh);
}
}
# A source directive can point to a glob of extra interface files.
elsif ($line =~ /^\s*source\s+(\S+)/) {
foreach my $src (glob($1)) {
if (&net_has_ifupdown_config($src, $done)) {
close($fh);
return 1;
}
}
}
}
close($fh);
return 0;
}
# net_has_dhcpcd_config([dhcpcd-conf], [service-active])
# Returns true if dhcpcd appears to own interface startup config
sub net_has_dhcpcd_config
{
my ($file, $service_active) = @_;
$file ||= "/etc/dhcpcd.conf";
return 0 if (!-r $file);
# Explicit interface blocks are enough proof even if the daemon is down.
open(my $fh, "<", $file) || return 0;
while(my $line = <$fh>) {
$line =~ s/#.*$//;
if ($line =~ /^\s*interface\s+\S+/) {
close($fh);
return 1;
}
}
close($fh);
# Default dhcpcd configs often have no interface blocks, so require service
# evidence before treating the file as the active startup backend.
return defined($service_active) ? $service_active :
&net_dhcpcd_service_active();
}
# net_dhcpcd_service_active()
# Returns true if the dhcpcd service is active or enabled
sub net_dhcpcd_service_active
{
if (&has_command("systemctl")) {
foreach my $unit ("dhcpcd.service", "dhcpcd5.service") {
my $q = quotemeta($unit);
# Active means dhcpcd is managing interfaces right now.
my $active = &backquote_command(
"systemctl is-active $q 2>/dev/null </dev/null");
return 1 if ($active =~ /^active\b/);
# Enabled/static service units mean dhcpcd will manage them at boot.
my $enabled = &backquote_command(
"systemctl is-enabled $q 2>/dev/null </dev/null");
return 1 if ($enabled =~ /^(enabled|static|indirect|alias)\b/);
}
}
# Non-systemd systems and some old dhcpcd packages expose a pid file.
return 1 if (-r "/run/dhcpcd.pid" || -r "/var/run/dhcpcd.pid");
return 0;
}
# net_auto_backend(os-type, [netplan-dir], [nm-dir], [ifupdown-file], [dhcpcd-conf], [dhcpcd-active])
# Returns the auto-detected backend name, or undef for the OS default
sub net_auto_backend
{
my ($os_type, $netplan_dir, $nm_conn_dir) = @_;
my ($os_type, $netplan_dir, $nm_conn_dir, $ifupdown_file, $dhcpcd_file,
$dhcpcd_service_active) = @_;
# Netplan is the preferred modern Debian/Ubuntu network config backend.
return "netplan"
if ($os_type eq "debian-linux" &&
&net_has_netplan_config($netplan_dir));
# NetworkManager is common on desktop/server installs and owns its profiles.
return "nm"
if (($os_type eq "redhat-linux" || $os_type eq "debian-linux") &&
&net_has_network_manager_config($nm_conn_dir));
# dhcpcd is only a final Debian fallback when ifupdown is not configuring
# any real interfaces.
return "dhcpcd"
if ($os_type eq "debian-linux" &&
!&net_has_ifupdown_config($ifupdown_file) &&
&net_has_dhcpcd_config($dhcpcd_file, $dhcpcd_service_active));
return undef;
}

View File

@@ -36,6 +36,11 @@ elsif ($auto_net_mode eq "nm") {
do 'nm-lib.pl';
$net_mode = "nm";
}
elsif ($auto_net_mode eq "dhcpcd") {
# Special case for Debian systems managed by dhcpcd
do 'dhcpcd-lib.pl';
$net_mode = "dhcpcd";
}
else {
do "$gconfig{'os_type'}-lib.pl";
$net_mode = $gconfig{'os_type'};
@@ -52,18 +57,34 @@ local $line="";
&open_readfile(HOSTS, $config{'hosts_file'});
while($line=<HOSTS>) {
local $comment = 0;
local $comment_prefix = "";
local $leading = "";
local $inline_comment = "";
$line =~ s/\r|\n//g;
if ($line =~ s/^\s*#+\s*//) {
if ($line =~ s/^(\s*#+\s*)//) {
$comment = 1;
$comment_prefix = $1;
}
elsif ($line =~ s/^(\s+)//) {
# Preserve indentation if this file uses it for host rows.
$leading = $1;
}
if ($line =~ s/(\s+#.*)$//) {
# Keep inline comments attached to edited host rows.
$inline_comment = $1;
}
$line =~ s/#.*$//g;
$line =~ s/\s+$//g;
local @seps = &host_line_separators($line);
local @f = split(/\s+/, $line);
local $ipaddr = shift(@f);
if (check_ipaddress_any($ipaddr)) {
push(@rv, { 'address' => $ipaddr,
'hosts' => [ @f ],
'active' => !$comment,
'comment_prefix' => $comment_prefix,
'leading' => $leading,
'comment' => $inline_comment,
'seps' => \@seps,
'line', $lnum,
'index', scalar(@rv) });
}
@@ -73,13 +94,35 @@ close(HOSTS);
return @rv;
}
# host_line_separators(line)
# Returns the field separators from a parsed /etc/hosts line
sub host_line_separators
{
local ($line) = @_;
local @seps;
while($line =~ /\S+(\s+)/g) {
push(@seps, $1);
}
return @seps;
}
# make_host_line(&host)
# Internal function to return a line for the hosts file
sub make_host_line
{
local ($host) = @_;
return ($host->{'active'} ? "" : "# ").
$host->{'address'}."\t".join(" ",@{$host->{'hosts'}})."\n";
local $prefix = $host->{'active'} ? $host->{'leading'} || "" :
$host->{'comment_prefix'} || "# ";
local @seps = @{$host->{'seps'} || [ ]};
local @hosts = @{$host->{'hosts'} || [ ]};
local $line = $prefix.$host->{'address'};
for(local $i=0; $i<@hosts; $i++) {
# Reuse original spacing by field position, then fall back to defaults.
local $sep = $seps[$i] || ($i == 0 ? "\t" : " ");
$line .= $sep.$hosts[$i];
}
$line .= $host->{'comment'} if ($host->{'comment'});
return $line."\n";
}
# create_host(&host)
@@ -485,4 +528,3 @@ return $a->{'virtual'} eq '' ? -1 :
}
1;

View File

@@ -49,14 +49,14 @@ else {
# also creating a virtual interface
foreach $ea (@acts) {
if ($ea->{'name'} eq $1 &&
$ea->{'virtual'} eq $3) {
$ea->{'virtual'} eq $4) {
&error(&text('aifc_evirtdup', &html_escape($in{'name'})));
}
}
$3 >= $min_virtual_number ||
$4 >= $min_virtual_number ||
&error(&text('aifc_evirtmin', &html_escape($min_virtual_number)));
$a->{'name'} = $1;
$a->{'virtual'} = $3;
$a->{'virtual'} = $4;
$a->{'fullname'} = $a->{'name'}.":".$a->{'virtual'};
&can_create_iface() || &error($text{'ifcs_ecannot'});
&can_iface($a) || &error($text{'ifcs_ecannot'});
@@ -131,7 +131,14 @@ else {
}
# Save active flag
if (!$access{'up'}) {
if ($a->{'virtual'} ne "") {
# Virtual aliases are addresses only, so present means up.
if ($access{'up'} && defined($in{'up'}) && !$in{'up'}) {
&error($text{'aifc_evirtdown'});
}
$a->{'up'} = 1;
}
elsif (!$access{'up'}) {
$a->{'up'} = $in{'new'} ? 1 : $olda->{'up'};
}
elsif ($in{'up'}) {
@@ -175,7 +182,8 @@ else {
delete($a->{'netmask6'});
}
if (!$in{'ether_def'} && $a->{'virtual'} eq "" &&
if (defined($in{'ether'}) && $in{'ether'} ne '' &&
!$in{'ether_def'} && $a->{'virtual'} eq "" &&
&iface_hardware($a->{'name'})) {
$in{'ether'} =~ /^[A-Fa-f0-9:]+$/ ||
&error(&text('aifc_ehard', &html_escape($in{'ether'})));
@@ -191,4 +199,3 @@ else {
"aifc", $a->{'fullname'}, $a);
}
&redirect("list_ifcs.cgi?mode=active");

View File

@@ -17,7 +17,14 @@ if ($in{'delete'} || $in{'unapply'}) {
&error_setup($text{'bifc_err4'});
@active = &active_interfaces();
($act) = grep { $_->{'fullname'} eq $b->{'fullname'} } @active;
if ($act) {
if (!$act && $b->{'virtual'} ne '' && $b->{'address'}) {
# ip(8) may renumber unlabelled secondary addresses.
($act) = grep { $_->{'virtual'} ne '' &&
$_->{'name'} eq $b->{'name'} &&
$_->{'address'} eq $b->{'address'} } @active;
}
if ($act && !defined(&unapply_interface_after_delete)) {
# Legacy backends remove live state before deleting config.
if (defined(&unapply_interface)) {
$err = &unapply_interface($act);
$err && &error("<pre>$err</pre>");
@@ -29,6 +36,12 @@ if ($in{'delete'} || $in{'unapply'}) {
}
&delete_interface($b);
if ($in{'unapply'} && $act && defined(&unapply_interface_after_delete)) {
# Config-driven backends apply removals after deleting config.
&error_setup($text{'bifc_err4'});
$err = &unapply_interface_after_delete($act, $b);
$err && &error("<pre>$err</pre>");
}
&webmin_log("delete", "bifc", $b->{'fullname'}, $b);
}
else {
@@ -59,19 +72,19 @@ else {
&can_create_iface() || &error($text{'ifcs_ecannot'});
&can_iface($b) || &error($text{'ifcs_ecannot'});
}
elsif ($in{'name'} =~ /^([a-z]+\d*(s\d*)?(\.\d+)?):(\d+)$/ ||
$in{'name'} =~ /^(en[0-9a-z]+(s\d*)?(\.\d+)?):(\d+)$/) {
elsif ($in{'name'} =~ /^((?:[a-z]+\d*(?:s\d*)?(?:\.\d+)?)|(?:en[0-9a-z]+(?:s\d*)?(?:\.\d+)?)):(\d+)$/) {
# also creating a virtual interface
local ($vname, $vnum) = ($1, $2);
foreach $eb (@boot) {
if ($eb->{'name'} eq $2 &&
$eb->{'virtual'} eq $4) {
if ($eb->{'name'} eq $vname &&
$eb->{'virtual'} eq $vnum) {
&error(&text('bifc_evirtdup', &html_escape($in{'name'})));
}
}
$4 >= $min_virtual_number ||
$vnum >= $min_virtual_number ||
&error(&text('aifc_evirtmin', &html_escape($min_virtual_number)));
$b->{'name'} = $1;
$b->{'virtual'} = $4;
$b->{'name'} = $vname;
$b->{'virtual'} = $vnum;
$b->{'fullname'} = $b->{'name'}.":".$b->{'virtual'};
}
elsif ($in{'bridge'}) {
@@ -376,4 +389,3 @@ else {
"bifc", $b->{'fullname'}, $b);
}
&redirect("list_ifcs.cgi?mode=boot");

View File

@@ -124,6 +124,19 @@ if (&foreign_installed("postfix") && $in{'hostname'} ne $old_hostname) {
&postfix::set_current_value("mydestination",
join(", ", @mydests));
}
$old_domain = $old_hostname =~ /^[^\.]+\.(.*)$/ ? $1 :
$old_fqdn =~ /^[^\.]+\.(.*)$/ ? $1 : undef;
$new_domain = $in{'hostname'} =~ /^[^\.]+\.(.*)$/ ? $1 :
$new_fqdn =~ /^[^\.]+\.(.*)$/ ? $1 : undef;
if ($old_domain && $new_domain &&
lc($old_domain) ne lc($new_domain)) {
$idx = &indexoflc("localhost.$old_domain", @mydests);
if ($idx >= 0) {
$mydests[$idx] = "localhost.$new_domain";
&postfix::set_current_value("mydestination",
join(", ", @mydests));
}
}
# Update postfix myorigin
$myorigin = &postfix::get_current_value("myorigin");
@@ -147,4 +160,3 @@ if (&foreign_installed("postfix") && $in{'hostname'} ne $old_hostname) {
&webmin_log("dns", undef, undef, \%in);
&redirect("");

View File

@@ -99,8 +99,16 @@ my $detect_netplan = "$detect_root/netplan";
my $detect_no_netplan = "$detect_root/no-netplan";
my $detect_nm = "$detect_root/NetworkManager/system-connections";
my $detect_nm_empty = "$detect_root/NetworkManager-empty/system-connections";
my $detect_ifupdown = "$detect_root/interfaces";
my $detect_ifupdown_empty = "$detect_root/interfaces-empty";
my $detect_ifupdown_dir = "$detect_root/interfaces.d";
my $detect_dhcpcd = "$detect_root/dhcpcd.conf";
make_path($detect_netplan, $detect_nm, $detect_nm_empty);
write_text("$detect_nm/eth0.nmconnection", "");
make_path($detect_ifupdown_dir);
write_text($detect_ifupdown, "source $detect_ifupdown_dir/*\n");
write_text($detect_ifupdown_empty, "# empty\n");
write_text($detect_dhcpcd, "# default dhcpcd configuration\nhostname\n");
is(main::net_auto_backend("debian-linux", $detect_netplan, $detect_nm_empty),
"netplan", "Debian uses Netplan when the config directory exists");
@@ -112,8 +120,27 @@ write_text("$detect_netplan/50-cloud-init.yaml", "");
is(main::net_auto_backend("debian-linux", $detect_netplan, $detect_nm),
"netplan", "Debian prefers Netplan over NetworkManager when YAML exists");
unlink("$detect_netplan/50-cloud-init.yaml");
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty),
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty,
$detect_ifupdown_empty, $detect_dhcpcd, 0),
undef, "Debian falls back when no Netplan or NetworkManager config exists");
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty,
$detect_ifupdown_empty, $detect_dhcpcd, 0),
undef, "Debian does not use inactive default dhcpcd config");
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty,
$detect_ifupdown_empty, $detect_dhcpcd, 1),
"dhcpcd", "Debian uses active dhcpcd service with default config");
write_text($detect_dhcpcd, "interface eth0\n");
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty,
$detect_ifupdown_empty, $detect_dhcpcd, 0),
"dhcpcd", "Debian uses dhcpcd only as a final configured backend");
write_text($detect_ifupdown_empty, "auto lo\niface lo inet loopback\n");
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty,
$detect_ifupdown_empty, $detect_dhcpcd),
"dhcpcd", "Debian loopback-only ifupdown config does not block dhcpcd");
write_text("$detect_ifupdown_dir/eth0", "iface eth0 inet dhcp\n");
is(main::net_auto_backend("debian-linux", $detect_no_netplan, $detect_nm_empty,
$detect_ifupdown, $detect_dhcpcd),
undef, "Debian does not prefer dhcpcd over ifupdown iface stanzas");
do "$root/net/netplan-lib.pl" || die "netplan-lib.pl: $@ $!";
@@ -122,6 +149,15 @@ do "$root/net/netplan-lib.pl" || die "netplan-lib.pl: $@ $!";
$main::netplan_dir = $tmp;
}
is(main::linux_nsswitch_hosts_line("hosts: files dns\n",
"files dns"),
"hosts: files dns\n",
"Linux DNS save preserves nsswitch hosts spacing");
is(main::linux_nsswitch_hosts_line("hosts:\tfiles dns # local policy\n",
"files mdns4 dns"),
"hosts:\tfiles mdns4 dns # local policy\n",
"Linux DNS save preserves nsswitch hosts comments");
my $netplan = "$tmp/50-cloud-init.yaml";
write_text($netplan, <<'YAML');
network:
@@ -232,4 +268,348 @@ main::save_interface($nmiface, [ $nmiface ]);
like(join("\n", @commands), qr/ipv6\.dns/,
"NetworkManager save_interface writes IPv6 nameservers");
do "$root/net/dhcpcd-lib.pl" || die "dhcpcd-lib.pl: $@ $!";
my $dhcpcd = "$tmp/dhcpcd.conf";
write_text($dhcpcd, <<'DHCPCD');
# global option
hostname
interface eth0
static ip_address=192.168.1.10/24
static routers=192.168.1.1
static routes=10.10.0.0/16 192.168.1.254 10.20.30.0/24 192.168.1.253
static domain_name_servers=1.1.1.1 2001:4860:4860::8888
static domain_search=example.com
static ip6_address=2001:db8::10/64
mtu 1400
noipv6rs
interface wlan0
# DHCP by default
DHCPCD
{
no warnings 'once';
$main::dhcpcd_config = $dhcpcd;
$main::dhcpcd_synthesize_implicit = 0;
}
@boot = main::boot_interfaces();
is(scalar(grep { $_->{'virtual'} eq '' } @boot), 2,
"dhcpcd parses two real interfaces");
my ($dh0) = grep { $_->{'fullname'} eq "eth0" } @boot;
is($dh0->{'address'}, "192.168.1.10", "dhcpcd parses static IPv4");
is($dh0->{'netmask'}, "255.255.255.0", "dhcpcd parses IPv4 prefix");
is($dh0->{'gateway'}, "192.168.1.1", "dhcpcd parses router");
is_deeply($dh0->{'routes'},
[ "10.10.0.0/16,192.168.1.254",
"10.20.30.0/24,192.168.1.253" ],
"dhcpcd parses static routes");
is_deeply($dh0->{'nameserver'},
[ "1.1.1.1", "2001:4860:4860::8888" ],
"dhcpcd parses nameservers");
is_deeply($dh0->{'address6'}, [ "2001:db8::10" ],
"dhcpcd parses static IPv6");
my ($wlan0) = grep { $_->{'fullname'} eq "wlan0" } @boot;
ok($wlan0->{'dhcp'}, "dhcpcd treats no static address as DHCP");
$dh0->{'address'} = "192.168.1.20";
$dh0->{'gateway'} = "192.168.1.254";
$dh0->{'routes'} = [ "172.16.0.0/12,192.168.1.253" ];
$dh0->{'nameserver'} = [ "9.9.9.9" ];
$dh0->{'search'} = [ "example.net" ];
main::save_interface($dh0, \@boot);
$saved = read_text($dhcpcd);
like($saved, qr/interface eth0\nnoipv6rs\nstatic ip_address=192\.168\.1\.20\/24\nstatic routers=192\.168\.1\.254\nstatic routes=172\.16\.0\.0\/12 192\.168\.1\.253\nstatic domain_name_servers=9\.9\.9\.9\nstatic domain_search=example\.net\nstatic ip6_address=2001:db8::10\/64\nmtu 1400/s,
"dhcpcd save_interface rewrites managed values and preserves extras");
like($saved, qr/interface wlan0\n# DHCP by default/s,
"dhcpcd save_interface preserves sibling DHCP block");
my ($dhwlan0) = grep { $_->{'fullname'} eq "wlan0" } main::boot_interfaces();
push(@boot, { 'name' => 'wlan0',
'fullname' => 'wlan0:0',
'virtual' => 0,
'address' => '192.168.2.10',
'netmask' => '255.255.255.0' });
main::save_interface($dhwlan0, \@boot);
$saved = read_text($dhcpcd);
like($saved, qr/interface wlan0\nstatic ip_address=192\.168\.2\.10\/24/s,
"dhcpcd writes virtual static address for DHCP parent interface");
write_text($dhcpcd, <<'DHCPCD');
interface enp0s5
static ip_address=10.211.55.20/24
static domain_name_servers=1.1.1.1 8.8.8.8
DHCPCD
my ($dhneed, $dhgenerated) = main::os_save_dns_config(
{ 'nameserver' => [ "1.1.1.1", "8.8.1.1" ],
'domain' => [ "example.test" ] });
ok($dhneed, "dhcpcd DNS save requests dhcpcd apply");
ok($dhgenerated, "dhcpcd DNS save reports resolv.conf as generated");
$saved = read_text($dhcpcd);
like($saved, qr/static domain_name_servers=1\.1\.1\.1 8\.8\.1\.1\nstatic domain_search=example\.test/,
"dhcpcd DNS save updates interface DNS settings");
@commands = ( );
($dhneed, $dhgenerated) = main::os_save_dns_config(
{ 'nameserver' => [ "1.1.1.1", "8.8.1.1" ],
'domain' => [ "example.test" ] });
ok(!$dhneed, "dhcpcd DNS save skips apply when unchanged");
ok($dhgenerated, "dhcpcd unchanged DNS still suppresses resolv.conf rewrite");
is_deeply(\@commands, [ ], "dhcpcd unchanged DNS does not rewrite config");
write_text($dhcpcd, <<'DHCPCD');
# default dhcpcd configuration
hostname
DHCPCD
{
no warnings 'once';
$main::dhcpcd_synthesize_implicit = 1;
$main::dhcpcd_test_active_interfaces = [
{ 'name' => 'lo', 'fullname' => 'lo', 'virtual' => '',
'up' => 1 },
{ 'name' => 'enp0s5', 'fullname' => 'enp0s5',
'virtual' => '', 'up' => 1 },
{ 'name' => 'wlan0', 'fullname' => 'wlan0',
'virtual' => '', 'up' => 1 },
];
}
@boot = main::boot_interfaces();
is_deeply([ map { $_->{'fullname'} } @boot ], [ "enp0s5", "wlan0" ],
"dhcpcd synthesizes implicit DHCP boot interfaces");
ok((grep { $_->{'fullname'} eq "enp0s5" && $_->{'dhcp'} &&
$_->{'implicit'} } @boot),
"dhcpcd marks synthesized interface as implicit DHCP");
my ($implicit) = grep { $_->{'fullname'} eq "enp0s5" } @boot;
main::delete_interface($implicit);
$saved = read_text($dhcpcd);
like($saved, qr/^denyinterfaces enp0s5\n# default dhcpcd configuration/m,
"dhcpcd delete of implicit interface writes denyinterfaces");
@boot = main::boot_interfaces();
is_deeply([ map { $_->{'fullname'} } @boot ], [ "wlan0" ],
"dhcpcd does not synthesize denied interfaces");
my $newif = { 'name' => 'enp0s5',
'fullname' => 'enp0s5',
'virtual' => '',
'dhcp' => 1,
'address6' => [ ],
'netmask6' => [ ] };
main::save_interface($newif, \@boot);
$saved = read_text($dhcpcd);
unlike($saved, qr/^denyinterfaces/m,
"dhcpcd save removes denyinterfaces for managed interface");
like($saved, qr/interface enp0s5\n(?:\n|$)/,
"dhcpcd save creates explicit DHCP interface block");
write_text($dhcpcd, <<'DHCPCD');
denyinterfaces eth0 wlan0
interface eth0
static ip_address=192.168.1.30/24
DHCPCD
{
no warnings 'once';
$main::dhcpcd_synthesize_implicit = 0;
}
@boot = main::boot_interfaces();
my ($denied_eth0) = grep { $_->{'fullname'} eq "eth0" } @boot;
$denied_eth0->{'address'} = "192.168.1.31";
main::save_interface($denied_eth0, \@boot);
$saved = read_text($dhcpcd);
like($saved, qr/^denyinterfaces wlan0\ninterface eth0\nstatic ip_address=192\.168\.1\.31\/24/m,
"dhcpcd save removes one denyinterfaces word without shifting block replacement");
write_text($dhcpcd, <<'DHCPCD');
denyinterfaces enp0s6 wlan0
interface enp0s6
static ip_address=10.211.55.21/24
DHCPCD
@boot = main::boot_interfaces();
my ($removed_enp0s6) = grep { $_->{'fullname'} eq "enp0s6" } @boot;
main::delete_interface($removed_enp0s6);
$saved = read_text($dhcpcd);
like($saved, qr/^denyinterfaces wlan0\n/m,
"dhcpcd delete of explicit interface removes stale denyinterfaces word");
unlike($saved, qr/^interface enp0s6/m,
"dhcpcd delete of explicit interface removes its block entirely");
write_text($dhcpcd, <<'DHCPCD');
allowinterfaces eth0
interface eth0
DHCPCD
@boot = main::boot_interfaces();
my $allowed_new = { 'name' => 'wlan0',
'fullname' => 'wlan0',
'virtual' => '',
'dhcp' => 1,
'address6' => [ ],
'netmask6' => [ ] };
main::save_interface($allowed_new, \@boot);
$saved = read_text($dhcpcd);
like($saved, qr/^allowinterfaces eth0 wlan0\ninterface eth0\n\ninterface wlan0/m,
"dhcpcd save adds new explicit interface to existing allowinterfaces");
{
no warnings 'once';
$main::dhcpcd_test_active_interfaces = [
{ 'name' => 'enp0s5', 'fullname' => 'enp0s5',
'virtual' => '', 'up' => 1 },
];
}
@commands = ( );
like(main::apply_interface({ 'name' => 'enp0s6',
'fullname' => 'enp0s6',
'virtual' => '' }),
qr/Cannot find device "enp0s6"/,
"dhcpcd apply reports missing real device");
is_deeply(\@commands, [ ], "dhcpcd apply skips restart for missing device");
@commands = ( );
is(main::apply_interface({ 'name' => 'enp0s5',
'fullname' => 'enp0s5',
'virtual' => '' }),
undef, "dhcpcd apply restarts service for existing device");
is_deeply(\@commands, [ "/etc/init.d/dhcpcd restart 2>&1 </dev/null" ],
"dhcpcd apply runs restart command for existing device");
@commands = ( );
write_text($dhcpcd, <<'DHCPCD');
interface enp0s5
static ip_address=10.211.55.20/24
DHCPCD
{
no warnings 'redefine';
no warnings 'once';
$main::dhcpcd_synthesize_implicit = 0;
$main::dhcpcd_test_active_interfaces = [
{ 'name' => 'enp0s5',
'fullname' => 'enp0s5',
'virtual' => '',
'address' => '10.211.55.20',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 1 },
{ 'name' => 'enp0s5',
'fullname' => 'enp0s5:1',
'virtual' => 1,
'address' => '10.211.55.21',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 1 },
];
local *main::has_command = sub {
return $_[0] eq "ip" ? "/sbin/ip" : undef;
};
is(main::apply_network(), undef,
"dhcpcd global apply removes virtual alias missing from config");
}
is_deeply(\@commands,
[ "ip addr del 10\\.211\\.55\\.21\\/24 dev enp0s5 2>&1",
"/etc/init.d/dhcpcd restart 2>&1 </dev/null" ],
"dhcpcd global apply removes live virtual address before restart");
@commands = ( );
write_text($dhcpcd, <<'DHCPCD');
interface enp0s5
static ip_address=10.211.55.20/24
static ip_address=10.211.55.23/24
DHCPCD
{
no warnings 'redefine';
no warnings 'once';
$main::dhcpcd_synthesize_implicit = 0;
$main::dhcpcd_test_active_interfaces = [
{ 'name' => 'enp0s5',
'fullname' => 'enp0s5',
'virtual' => '',
'address' => '10.211.55.20',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 1 },
{ 'name' => 'enp0s5',
'fullname' => 'enp0s5:0',
'virtual' => 0,
'address' => '10.211.55.24',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 1 },
{ 'name' => 'enp0s5',
'fullname' => 'enp0s5:1',
'virtual' => 1,
'address' => '10.211.55.23',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 1 },
];
local *main::has_command = sub {
return $_[0] eq "ip" ? "/sbin/ip" : undef;
};
is(main::delete_active_interface($main::dhcpcd_test_active_interfaces->[2]),
undef,
"dhcpcd active delete removes matching boot virtual alias");
}
$saved = read_text($dhcpcd);
unlike($saved, qr/static ip_address=10\.211\.55\.23\/24/,
"dhcpcd active delete removes alias from config");
is_deeply(\@commands,
[ "ip addr del 10\\.211\\.55\\.23\\/24 dev enp0s5 2>&1" ],
"dhcpcd active delete drops only the selected live virtual alias");
@commands = ( );
{
no warnings 'redefine';
local *main::has_command = sub {
return $_[0] eq "ip" ? "/sbin/ip" : undef;
};
local *main::active_interfaces = sub {
return ( );
};
main::activate_interface({ 'name' => 'enp0s5',
'fullname' => 'enp0s5:1',
'virtual' => 1,
'address' => '10.211.55.25',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 0 });
}
is_deeply(\@commands, [ ],
"Linux active virtual interface stays absent when created down");
@commands = ( );
{
no warnings 'redefine';
local *main::has_command = sub {
return $_[0] eq "ip" ? "/sbin/ip" : undef;
};
local *main::active_interfaces = sub {
return ({ 'name' => 'enp0s5',
'fullname' => 'enp0s5:1',
'virtual' => 1,
'address' => '10.211.55.25',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 1 });
};
main::activate_interface({ 'name' => 'enp0s5',
'fullname' => 'enp0s5:1',
'virtual' => 1,
'address' => '10.211.55.25',
'netmask' => '255.255.255.0',
'address6' => [ ],
'netmask6' => [ ],
'up' => 0 });
}
is_deeply(\@commands,
[ "ip addr del 10\\.211\\.55\\.25\\/24 dev enp0s5 2>&1" ],
"Linux active virtual interface is removed when saved down");
done_testing();