From cb4a3220428dd323c247ec094c81b5564e3e444b Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 20 Jun 2026 15:23:09 +0200 Subject: [PATCH 1/3] Fix active virtual interface handling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ⓘ Treat Linux active virtual interfaces as secondary IP addresses instead of independent links, fixing alias parsing, hiding invalid status controls, rejecting down-state creation, and removing existing aliases with ip addr del when needed. Reproduce path: Example repro before this fix: 1. Go to **Network Configuration → Network Interfaces → Active Now**. 2. Click **Add a new interface**. 3. Enter: ```text Name: enp0s5:1 IPv4 address: 10.211.55.21 Netmask: 255.255.255.0 Status: Down ``` 4. Click **Create**. Before the fix, Webmin could still create the alias or handle it inconsistently, because `enp0s5:1` is not a real link that can be “down”. It is just an extra IP address on `enp0s5`. Expected after the fix: - The UI should not offer `Status` for active virtual aliases. - If someone submits `up=0` manually anyway, Webmin rejects it with: `Virtual interfaces cannot be created with down status` - If an existing active virtual alias is saved as down through lower-level code, Webmin removes the IP using something like: ```bash ip addr del 10.211.55.21/24 dev enp0s5 ``` --- net/edit_aifc.cgi | 9 ++++++-- net/lang/en | 1 + net/linux-lib.pl | 6 ++++++ net/save_aifc.cgi | 18 +++++++++++----- net/save_bifc.cgi | 14 ++++++------- net/t/run-tests.t | 52 +++++++++++++++++++++++++++++++++++++++++++++++ 6 files changed, 86 insertions(+), 14 deletions(-) diff --git a/net/edit_aifc.cgi b/net/edit_aifc.cgi index 1f1424de0..2bdb47d85 100755 --- a/net/edit_aifc.cgi +++ b/net/edit_aifc.cgi @@ -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 "") || diff --git a/net/lang/en b/net/lang/en index ad2a2db77..26b660bbc 100644 --- a/net/lang/en +++ b/net/lang/en @@ -79,6 +79,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 diff --git a/net/linux-lib.pl b/net/linux-lib.pl index e542187e1..47f0c733e 100755 --- a/net/linux-lib.pl +++ b/net/linux-lib.pl @@ -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'})) { diff --git a/net/save_aifc.cgi b/net/save_aifc.cgi index 9fe9ecc65..d0c874128 100755 --- a/net/save_aifc.cgi +++ b/net/save_aifc.cgi @@ -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'}))); diff --git a/net/save_bifc.cgi b/net/save_bifc.cgi index 3cd814542..5c9be35df 100755 --- a/net/save_bifc.cgi +++ b/net/save_bifc.cgi @@ -59,19 +59,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'}) { diff --git a/net/t/run-tests.t b/net/t/run-tests.t index 21f936e3e..bb695d79d 100644 --- a/net/t/run-tests.t +++ b/net/t/run-tests.t @@ -232,4 +232,56 @@ main::save_interface($nmiface, [ $nmiface ]); like(join("\n", @commands), qr/ipv6\.dns/, "NetworkManager save_interface writes IPv6 nameservers"); +do "$root/net/linux-lib.pl" || die "linux-lib.pl: $@ $!"; + +@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(); From c08468ec48de8107c5efe5654198b98a069dffa1 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 20 Jun 2026 15:27:28 +0200 Subject: [PATCH 2/3] Fix network config spacing preservation - Preserve existing spacing and inline comments when rewriting `/etc/nsswitch.conf` `hosts:` lines. - Preserve indentation, comment prefix, inline comments, and field separators when rewriting `/etc/hosts` rows. - Add tests for the `nsswitch.conf` spacing/comment behavior. --- net/linux-lib.pl | 19 ++++++++++++++++++- net/net-lib.pl | 46 ++++++++++++++++++++++++++++++++++++++++++---- net/t/run-tests.t | 9 +++++++++ 3 files changed, 69 insertions(+), 5 deletions(-) diff --git a/net/linux-lib.pl b/net/linux-lib.pl index 47f0c733e..06a7014b9 100755 --- a/net/linux-lib.pl +++ b/net/linux-lib.pl @@ -948,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, $_); @@ -1013,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; diff --git a/net/net-lib.pl b/net/net-lib.pl index 318dcfd4d..5659ed9c1 100755 --- a/net/net-lib.pl +++ b/net/net-lib.pl @@ -52,18 +52,34 @@ local $line=""; &open_readfile(HOSTS, $config{'hosts_file'}); while($line=) { 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 +89,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) diff --git a/net/t/run-tests.t b/net/t/run-tests.t index bb695d79d..16f86c447 100644 --- a/net/t/run-tests.t +++ b/net/t/run-tests.t @@ -122,6 +122,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: From 0cf6654fd95b94da7135ef257b75bbd381bb815d Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 20 Jun 2026 15:33:22 +0200 Subject: [PATCH 3/3] Fix Postfix localhost destination after hostname domain change - When the system hostname domain changes, update `localhost.` in Postfix `mydestination` to `localhost.`. - This sits alongside the existing hostname/FQDN updates for Postfix destinations. Previous behavior: `save_dns.cgi` only updated Postfix `mydestination` entries that exactly matched: - the old short hostname, like `host` - the old FQDN, like `host.old-domain.test` It did **not** update: - `localhost.old-domain.test` So if you changed: ```text host.old-domain.test ``` to: ```text host.new-domain.test ``` Postfix could become: ```text mydestination = host.new-domain.test, host, localhost.old-domain.test ``` After this hunk, it also updates that localhost domain entry: ```text localhost.old-domain.test ``` to: ```text localhost.new-domain.test ``` --- net/save_dns.cgi | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/net/save_dns.cgi b/net/save_dns.cgi index 876bb50be..29207db54 100755 --- a/net/save_dns.cgi +++ b/net/save_dns.cgi @@ -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");