diff --git a/net/delete_aifcs.cgi b/net/delete_aifcs.cgi index 9161e6892..a54931531 100755 --- a/net/delete_aifcs.cgi +++ b/net/delete_aifcs.cgi @@ -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("
$err
"); + } + else { + &deactivate_interface($a); + } } &webmin_log("delete", "aifcs", scalar(@d)); &redirect("list_ifcs.cgi?mode=active"); - diff --git a/net/delete_bifcs.cgi b/net/delete_bifcs.cgi index 4766fc7af..aeff092b0 100755 --- a/net/delete_bifcs.cgi +++ b/net/delete_bifcs.cgi @@ -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("
$err
"); + } + 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("
$err
"); - } - 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("
$err
"); + } + 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("
$err
"); + } } } &webmin_log($in{'apply'} ? "apply" : "delete", "bifcs", scalar(@d)); &redirect("list_ifcs.cgi?mode=boot"); - diff --git a/net/dhcpcd-lib.pl b/net/dhcpcd-lib.pl new file mode 100644 index 000000000..9b9a65f7c --- /dev/null +++ b/net/dhcpcd-lib.pl @@ -0,0 +1,1004 @@ +# Networking functions for dhcpcd + +$dhcpcd_config = "/etc/dhcpcd.conf"; +$sysctl_config = "/etc/sysctl.conf"; + +do 'linux-lib.pl'; + +# boot_interfaces() +# Returns a list of all interfaces configured in dhcpcd.conf +sub boot_interfaces +{ +my $cfg = &read_dhcpcd_config($dhcpcd_config); +my @rv; +foreach my $block (@$cfg) { + # Only interface blocks map to Webmin boot-time interfaces. + next if ($block->{'type'} ne 'interface'); + my $iface = { 'name' => $block->{'name'}, + 'fullname' => $block->{'name'}, + 'virtual' => '', + 'file' => $dhcpcd_config, + 'block' => $block, + 'edit' => 1, + 'up' => 1, + 'address6' => [ ], + 'netmask6' => [ ] }; + + # The first static ip_address is the primary IPv4 address. + my @ip4 = &dhcpcd_values($block, "ip_address"); + if (@ip4) { + my ($addr, $prefix) = split(/\//, $ip4[0]); + $iface->{'address'} = $addr; + $iface->{'netmask'} = $prefix ? &prefix_to_mask($prefix) : undef; + + # Extra static ip_address directives become Webmin virtual aliases. + for(my $i=1; $i<@ip4; $i++) { + my ($vaddr, $vprefix) = split(/\//, $ip4[$i]); + push(@rv, { 'name' => $iface->{'name'}, + 'fullname' => $iface->{'name'}.":".($i-1), + 'virtual' => $i-1, + 'file' => $dhcpcd_config, + 'block' => $block, + 'edit' => 1, + 'up' => 1, + 'address' => $vaddr, + 'netmask' => $vprefix ? + &prefix_to_mask($vprefix) : undef }); + } + } + else { + # dhcpcd uses DHCP when no static ip_address is configured. + $iface->{'dhcp'} = 1; + } + + # Parse gateways and static IPv4 routes from dhcpcd's static keys. + my ($routers) = &dhcpcd_values($block, "routers"); + ($iface->{'gateway'}) = split(/\s+/, $routers) if ($routers); + my ($routes) = &dhcpcd_values($block, "routes"); + if ($routes) { + my @r = split(/\s+/, $routes); + while(@r >= 2) { + my $dest = shift(@r); + my $gw = shift(@r); + push(@{$iface->{'routes'}}, "$dest,$gw"); + } + } + + # DNS servers and search domains live inside the interface block. + my ($ns) = &dhcpcd_values($block, "domain_name_servers"); + $iface->{'nameserver'} = [ split(/\s+/, $ns) ] if ($ns); + my ($search) = &dhcpcd_values($block, "domain_search"); + $search ||= (&dhcpcd_values($block, "domain_name"))[0]; + $iface->{'search'} = [ split(/\s+/, $search) ] if ($search); + + # IPv6 addresses and router settings use separate dhcpcd directives. + foreach my $ip6 (&dhcpcd_values($block, "ip6_address")) { + my ($addr, $prefix) = split(/\//, $ip6); + push(@{$iface->{'address6'}}, $addr); + push(@{$iface->{'netmask6'}}, $prefix || 64); + } + my ($routers6) = &dhcpcd_values($block, "ip6_routers"); + ($iface->{'gateway6'}) = split(/\s+/, $routers6) if ($routers6); + my ($mtu) = &dhcpcd_plain_values($block, "mtu"); + $iface->{'mtu'} = $mtu if ($mtu); + + push(@rv, $iface); + } + +# Default dhcpcd setups may manage DHCP interfaces without explicit blocks. +push(@rv, &dhcpcd_implicit_interfaces($cfg, \@rv)); +@rv = sort { $a->{'fullname'} cmp $b->{'fullname'} } @rv; +for(my $i=0; $i<@rv; $i++) { + $rv[$i]->{'index'} = $i; + } +return @rv; +} + +# save_interface(&iface, [&all-interfaces]) +# Create or update a dhcpcd interface block +sub save_interface +{ +my ($iface, $boot) = @_; +$boot ||= [ &boot_interfaces() ]; +if ($iface->{'virtual'} ne '') { + # Virtual aliases are stored as extra ip_address lines on the parent. + my ($parent) = grep { $_->{'fullname'} eq $iface->{'name'} } @$boot; + $parent || &error("No interface named $iface->{'name'} exists"); + if (!$iface->{'block'}) { + push(@$boot, $iface); + } + else { + # Replace the existing alias in the in-memory boot list. + my ($oldiface) = grep { $_->{'fullname'} eq $iface->{'fullname'} } @$boot; + $oldiface || &error("No existing interface named $iface->{'fullname'} found"); + $boot->[$oldiface->{'index'}] = $iface; + } + &save_interface($parent, $boot); + return; + } + +my $cfg = &read_dhcpcd_config($dhcpcd_config); +my ($old) = grep { $_->{'type'} eq 'interface' && + $_->{'name'} eq $iface->{'fullname'} } @$cfg; +my @lines = &dhcpcd_interface_lines($iface, $boot); +&lock_file($dhcpcd_config); +my $lref = &read_file_lines($dhcpcd_config); +if ($old) { + # Replace only the old interface block, preserving file-level comments. + splice(@$lref, $old->{'line'}, $old->{'eline'} - $old->{'line'} + 1, + @lines); + } +else { + # Append a new explicit block, used for first edits to implicit DHCP rows. + push(@$lref, "") if (@$lref && $lref->[@$lref-1] =~ /\S/); + push(@$lref, @lines); + } + +# Saving an interface means dhcpcd should no longer deny managing it. +&dhcpcd_delete_global_word($lref, "denyinterfaces", $iface->{'fullname'}); + +# If allowinterfaces is present, the saved interface must be included there. +&dhcpcd_add_global_word_if_exists($lref, "allowinterfaces", + $iface->{'fullname'}); +&flush_file_lines($dhcpcd_config); +&unlock_file($dhcpcd_config); +} + +# delete_interface(&iface) +# Remove a dhcpcd interface block, or one virtual address from it +sub delete_interface +{ +my ($iface) = @_; +if ($iface->{'virtual'} ne '') { + # Removing an alias means re-saving the parent without that address. + my @boot = grep { $_->{'fullname'} ne $iface->{'fullname'} } + &boot_interfaces(); + my ($parent) = grep { $_->{'fullname'} eq $iface->{'name'} } @boot; + $parent || &error("No interface named $iface->{'name'} exists"); + &save_interface($parent, \@boot); + return; + } +&lock_file($dhcpcd_config); +my $lref = &read_file_lines($dhcpcd_config); +my $block = $iface->{'block'}; +if ($block) { + # Explicit blocks can be removed directly. + splice(@$lref, $block->{'line'}, $block->{'eline'} - $block->{'line'} + 1); + + # A deleted explicit block should be gone, not replaced by a deny. + &dhcpcd_delete_global_word($lref, "denyinterfaces", + $iface->{'fullname'}); + } +else { + # Implicit DHCP interfaces need a deny line, or they reappear. + &dhcpcd_add_global_word($lref, "denyinterfaces", + $iface->{'fullname'}); + } +&flush_file_lines($dhcpcd_config); +&unlock_file($dhcpcd_config); +} + +# can_edit(setting, [&iface]) +# Returns true if a boot-time interface setting can be edited +sub can_edit +{ +my ($what) = @_; +return $what !~ /^(up|bootp|broadcast|ether|bridgestp|bridgefd|bridgewait)$/; +} + +# can_broadcast_def() +# Returns true if broadcast address can be left to the OS +sub can_broadcast_def +{ +return 1; +} + +# valid_boot_address(address) +# Returns true if an IPv4 or IPv6 boot-time address is valid +sub valid_boot_address +{ +return &check_ipaddress_any($_[0]); +} + +# supports_address6([&iface]) +# Returns true if this backend supports IPv6 addresses +sub supports_address6 +{ +return 1; +} + +# supports_no_address([&iface]) +# Returns true if this backend supports explicit no-address interfaces +sub supports_no_address +{ +return 0; +} + +# supports_bridges() +# Returns true if this backend can create persistent bridge devices +sub supports_bridges +{ +return 0; +} + +# supports_bonding() +# Returns true if this backend can create persistent bonded devices +sub supports_bonding +{ +return 0; +} + +# supports_vlans() +# Returns true if this backend can create persistent VLAN devices +sub supports_vlans +{ +return 0; +} + +# apply_network() +# Applies dhcpcd configuration by restarting the dhcpcd service +sub apply_network +{ +&dhcpcd_remove_stale_virtual_addresses(); +my $cmd = &has_command("systemctl") ? "systemctl restart dhcpcd" : + &has_command("service") ? "service dhcpcd restart" : + "/etc/init.d/dhcpcd restart"; +my $out = &backquote_logged("$cmd 2>&1 {'virtual'} ne '' && $active->{'address'}) { + # dhcpcd can recreate configured aliases unless the config changes too. + my ($boot) = grep { $_->{'virtual'} ne '' && + &dhcpcd_address_key($_) eq + &dhcpcd_address_key($active) } @boot; + if ($boot) { + # Remove the persistent alias, then drop only the selected live IP. + &delete_interface($boot); + &deactivate_interface($active); + return undef; + } + } + +# Unmanaged or real active rows can still use the normal Linux action. +&deactivate_interface($active); +return undef; +} + +# dhcpcd_remove_stale_virtual_addresses() +# Removes live virtual IPv4 addresses missing from dhcpcd boot config +sub dhcpcd_remove_stale_virtual_addresses +{ +my @boot = &boot_interfaces(); +my %managed; +my %wanted; +foreach my $iface (@boot) { + if ($iface->{'virtual'} eq '') { + # Only clean up live aliases on dhcpcd-managed parents. + $managed{$iface->{'fullname'}} = 1; + } + elsif ($iface->{'address'}) { + # Keep aliases that still exist in dhcpcd.conf. + $wanted{&dhcpcd_address_key($iface)} = 1; + } + } +foreach my $active (&dhcpcd_active_interfaces()) { + # Live aliases missing from boot config would survive a restart. + next if ($active->{'virtual'} eq '' || !$active->{'address'}); + next if (!$managed{$active->{'name'}}); + next if ($wanted{&dhcpcd_address_key($active)}); + &deactivate_interface($active); + } +} + +# dhcpcd_address_key(&iface) +# Returns a stable parent/address key for comparing virtual IPv4 aliases +sub dhcpcd_address_key +{ +my ($iface) = @_; +return join("\0", $iface->{'name'}, $iface->{'address'}, + $iface->{'netmask'} || ""); +} + +# dhcpcd_apply_device(&iface) +# Returns the real device name needed to apply an interface config +sub dhcpcd_apply_device +{ +my ($iface) = @_; +return $iface->{'virtual'} ne '' ? $iface->{'name'} : + $iface->{'name'} || $iface->{'fullname'}; +} + +# dhcpcd_interface_exists(&iface) +# Returns true if the interface's real device exists right now +sub dhcpcd_interface_exists +{ +my ($iface) = @_; +my $dev = &dhcpcd_apply_device($iface); +return grep { $_->{'fullname'} eq $dev || $_->{'name'} eq $dev } + &dhcpcd_active_interfaces(); +} + +# routing_config_files() +# Returns files that affect boot-time routing +sub routing_config_files +{ +return ( $dhcpcd_config, $sysctl_config ); +} + +# network_config_files() +# Returns files that affect hostname/domain network settings +sub network_config_files +{ +return ( "/etc/hostname", "/etc/HOSTNAME", "/etc/mailname" ); +} + +# routing_input() +# Prints the boot-time routing form for dhcpcd +sub routing_input +{ +my ($addr, $router) = &get_default_gateway(); +my ($addr6, $router6) = &get_default_ipv6_gateway(); +my @ifaces = grep { $_->{'virtual'} eq '' } &boot_interfaces(); +my @inames = map { $_->{'name'} } @ifaces; + +# Default IPv4 and IPv6 gateways are stored on one interface block. +print &ui_table_row($text{'routes_default'}, + &ui_radio("gateway_def", $addr ? 0 : 1, + [ [ 1, $text{'routes_none'} ], + [ 0, $text{'routes_gateway'}." ". + &ui_textbox("gateway", $addr, 15)." ". + &ui_select("gatewaydev", $router, \@inames) ] ])); + +print &ui_table_row($text{'routes_default6'}, + &ui_radio("gateway6_def", $addr6 ? 0 : 1, + [ [ 1, $text{'routes_none'} ], + [ 0, $text{'routes_gateway'}." ". + &ui_textbox("gateway6", $addr6, 30)." ". + &ui_select("gatewaydev6", $router6, \@inames) ] ])); + +# Static routes are stored as destination/gateway pairs per interface. +my @routes; +foreach my $iface (@ifaces) { + foreach my $route (@{$iface->{'routes'} || [ ]}, "") { + my ($dest, $gw) = split(/,/, $route, 2); + push(@routes, [ + &ui_select("route_dev", $iface->{'name'}, \@inames), + &ui_textbox("route_dest", $dest, 20), + &ui_textbox("route_gw", $gw, 15), + ]); + } + } +push(@routes, [ + &ui_select("route_dev", undef, \@inames), + &ui_textbox("route_dest", undef, 20), + &ui_textbox("route_gw", undef, 15), + ]) if (!@ifaces); +print &ui_table_row($text{'routes_static'}, + &ui_columns_table([ $text{'routes_ifc'}, $text{'routes_dest'}, + $text{'routes_gateway'} ], 100, 0, \@routes)); + +# Forwarding remains the standard Linux sysctl setting. +my %sysctl; +&read_env_file($sysctl_config, \%sysctl); +print &ui_table_row($text{'routes_forward'}, + &ui_yesno_radio("forward", + $sysctl{'net.ipv4.ip_forward'} ? 1 : 0)); +} + +# parse_routing() +# Saves the boot-time routing form for dhcpcd +sub parse_routing +{ +my @boot = &boot_interfaces(); + +# Save the default IPv4 route, clearing it from any other interface. +my ($dev, $gw); +if (!$in{'gateway_def'}) { + &check_ipaddress($in{'gateway'}) || + &error(&text('routes_egateway', &html_escape($in{'gateway'}))); + $gw = $in{'gateway'}; + $dev = $in{'gatewaydev'}; + } +&set_default_gateway($gw, $dev, \@boot); + +# Save the default IPv6 route in the same single-interface style. +my ($dev6, $gw6); +if (!$in{'gateway6_def'}) { + &check_ip6address($in{'gateway6'}) || + &error(&text('routes_egateway6',&html_escape($in{'gateway6'}))); + $gw6 = $in{'gateway6'}; + $dev6 = $in{'gatewaydev6'}; + } +&set_default_ipv6_gateway($gw6, $dev6, \@boot); + +# Rebuild static routes from the submitted rows. +foreach my $iface (@boot) { + delete($iface->{'routes'}); + } +my @rdev = split(/\0/, $in{'route_dev'} || ""); +my @rdest = split(/\0/, $in{'route_dest'} || ""); +my @rgw = split(/\0/, $in{'route_gw'} || ""); +for(my $i=0; $i<@rdest; $i++) { + next if ($rdest[$i] eq '' && $rgw[$i] eq ''); + + # Each route must point at an existing boot-time interface. + my ($iface) = grep { $_->{'fullname'} eq $rdev[$i] } @boot; + $iface || &error(&text('routes_edevice', + &html_escape($rdev[$i]))); + + # dhcpcd static routes are IPv4 destination/gateway pairs here. + &valid_dhcpcd_route_dest($rdest[$i]) || + &error(&text('routes_enet', &html_escape($rdest[$i]))); + &check_ipaddress($rgw[$i]) || + &error(&text('routes_egateway', + &html_escape($rgw[$i]))); + push(@{$iface->{'routes'}}, "$rdest[$i],$rgw[$i]"); + } + +# Save all real interfaces so removed routes are written out too. +foreach my $iface (@boot) { + next if ($iface->{'virtual'} ne ''); + &save_interface($iface, \@boot); + } + +# Save Linux IPv4 forwarding alongside the dhcpcd routing settings. +my %sysctl; +&lock_file($sysctl_config); +&read_env_file($sysctl_config, \%sysctl); +$sysctl{'net.ipv4.ip_forward'} = $in{'forward'}; +&write_env_file($sysctl_config, \%sysctl); +&unlock_file($sysctl_config); +} + +# get_hostname() +# Returns the system hostname +sub get_hostname +{ +my $hn = &read_file_contents("/etc/hostname"); +$hn =~ s/\r|\n//g; +return $hn if ($hn); +return &get_system_hostname(); +} + +# save_hostname(hostname) +# Sets the system hostname in common Linux hostname files +sub save_hostname +{ +my ($hostname) = @_; +&system_logged("hostname ".quotemeta($hostname)." >/dev/null 2>&1"); + +# Update all hostname files that already exist on this system. +foreach my $f ("/etc/hostname", "/etc/HOSTNAME", "/etc/mailname") { + if (-r $f) { + &open_lock_tempfile(HOST, ">$f"); + &print_tempfile(HOST, $hostname,"\n"); + &close_tempfile(HOST); + } + } + +# hostnamectl keeps systemd's transient/static hostname in sync. +if (&has_command("hostnamectl")) { + &system_logged("hostnamectl set-hostname ".quotemeta($hostname). + " >/dev/null 2>&1"); + } +&get_system_hostname(undef, undef, 2); +} + +# get_domainname() +# Returns the current NIS domain name +sub get_domainname +{ +my $d; +&execute_command("domainname", undef, \$d, undef); +chop($d); +return $d; +} + +# save_domainname(domain) +# Sets the current NIS domain name +sub save_domainname +{ +my ($domain) = @_; +&execute_command("domainname ".quotemeta($domain)); +} + +# get_default_gateway() +# Returns the default IPv4 gateway and interface name +sub get_default_gateway +{ +foreach my $iface (&boot_interfaces()) { + return ( $iface->{'gateway'}, $iface->{'fullname'} ) + if ($iface->{'gateway'}); + } +return ( ); +} + +# set_default_gateway([gateway], [interface], [&boot-interfaces]) +# Sets the default IPv4 gateway on one dhcpcd interface +sub set_default_gateway +{ +my ($gw, $dev, $boot) = @_; +$boot ||= [ &boot_interfaces() ]; +foreach my $iface (@$boot) { + next if ($iface->{'virtual'} ne ''); + if ($iface->{'fullname'} eq $dev && $iface->{'gateway'} ne $gw) { + # Add or update the gateway on the selected interface. + $iface->{'gateway'} = $gw; + &save_interface($iface, $boot); + } + elsif ($iface->{'fullname'} ne $dev && $iface->{'gateway'}) { + # Remove old default gateways from all other interfaces. + delete($iface->{'gateway'}); + &save_interface($iface, $boot); + } + } +} + +# get_default_ipv6_gateway() +# Returns the default IPv6 gateway and interface name +sub get_default_ipv6_gateway +{ +foreach my $iface (&boot_interfaces()) { + return ( $iface->{'gateway6'}, $iface->{'fullname'} ) + if ($iface->{'gateway6'}); + } +return ( ); +} + +# set_default_ipv6_gateway([gateway], [interface], [&boot-interfaces]) +# Sets the default IPv6 gateway on one dhcpcd interface +sub set_default_ipv6_gateway +{ +my ($gw, $dev, $boot) = @_; +$boot ||= [ &boot_interfaces() ]; +foreach my $iface (@$boot) { + next if ($iface->{'virtual'} ne ''); + if ($iface->{'fullname'} eq $dev && $iface->{'gateway6'} ne $gw) { + # Add or update the IPv6 gateway on the selected interface. + $iface->{'gateway6'} = $gw; + &save_interface($iface, $boot); + } + elsif ($iface->{'fullname'} ne $dev && $iface->{'gateway6'}) { + # Remove old IPv6 default gateways from all other interfaces. + delete($iface->{'gateway6'}); + &save_interface($iface, $boot); + } + } +} + +# os_save_dns_config(&config) +# Saves DNS servers and search domains into dhcpcd interface blocks +sub os_save_dns_config +{ +my ($conf) = @_; +my @boot = &boot_interfaces(); +my $newns = @{$conf->{'nameserver'} || [ ]} ? $conf->{'nameserver'} : undef; +my $newsearch = @{$conf->{'domain'} || [ ]} ? $conf->{'domain'} : undef; + +# Prefer updating interfaces that already have DNS; otherwise use all real ones. +my @fix = grep { $_->{'nameserver'} } @boot; +@fix = grep { $_->{'virtual'} eq '' } @boot if (!@fix); +my $need_apply = 0; +foreach my $iface (@fix) { + # Skip unchanged interfaces to avoid needless dhcpcd restarts. + next if (&dhcpcd_same_list($iface->{'nameserver'}, $newns) && + &dhcpcd_same_list($iface->{'search'}, $newsearch)); + if ($newns) { + $iface->{'nameserver'} = $newns; + } + else { + delete($iface->{'nameserver'}); + } + if ($newsearch) { + $iface->{'search'} = $newsearch; + } + else { + delete($iface->{'search'}); + } + &save_interface($iface, \@boot); + $need_apply = 1; + } +return ($need_apply, 1); +} + +# dhcpcd_same_list(&list1, &list2) +# Returns true if two optional array refs contain the same values +sub dhcpcd_same_list +{ +my ($a, $b) = @_; +my @a = $a ? @$a : ( ); +my @b = $b ? @$b : ( ); +return 0 if (@a != @b); +for(my $i=0; $i<@a; $i++) { + return 0 if ($a[$i] ne $b[$i]); + } +return 1; +} + +# read_dhcpcd_config(file) +# Parses dhcpcd.conf into global/interface/profile/ssid blocks +sub read_dhcpcd_config +{ +my ($file) = @_; +my $lref = &read_file_lines($file); + +# Global settings are represented as a synthetic first block. +my $cur = { 'type' => 'global', + 'name' => '', + 'line' => 0, + 'eline' => -1, + 'members' => [ ] }; +my @rv = ( $cur ); +for(my $i=0; $i<@$lref; $i++) { + my $line = $lref->[$i]; + if ($line =~ /^\s*(interface|profile|ssid)\s+(\S+)/) { + # New dhcpcd block starts; close the previous explicit block. + $cur->{'eline'} = $i - 1 if ($cur); + $cur = { 'type' => $1, + 'name' => $2, + 'line' => $i, + 'eline' => $i, + 'members' => [ ] }; + push(@rv, $cur); + } + else { + # Blank/comment lines are not members but still belong to blocks. + $cur->{'eline'} = $i if ($cur->{'type'} ne 'global'); + my $member = &dhcpcd_line_member($line, $i); + push(@{$cur->{'members'}}, $member) if ($member); + } + } +return \@rv; +} + +# dhcpcd_line_member(line, line-number) +# Parses one non-block dhcpcd.conf line into a member hash +sub dhcpcd_line_member +{ +my ($line, $lnum) = @_; + +# Strip comments and surrounding whitespace before classifying the line. +my $clean = $line; +$clean =~ s/#.*$//; +$clean =~ s/^\s+//; +$clean =~ s/\s+$//; +return undef if ($clean eq ''); + +# Static dhcpcd settings use "static name=value" syntax. +if ($clean =~ /^static\s+([^=\s]+)=(.*)$/) { + return { 'static' => 1, + 'name' => $1, + 'value' => $2, + 'line' => $lnum }; + } +elsif ($clean =~ /^(\S+)\s+(.*)$/) { + # Plain settings usually use "name value" syntax. + return { 'name' => $1, + 'value' => $2, + 'line' => $lnum }; + } +elsif ($clean =~ /^(\S+)$/) { + # Flag settings such as noipv6rs have no value. + return { 'name' => $1, + 'value' => '', + 'line' => $lnum }; + } +return undef; +} + +# dhcpcd_values(&block, name) +# Returns all static values with the given name from a block +sub dhcpcd_values +{ +my ($block, $name) = @_; +return map { $_->{'value'} } + grep { $_->{'static'} && $_->{'name'} eq $name } + @{$block->{'members'}}; +} + +# dhcpcd_plain_values(&block, name) +# Returns all non-static values with the given name from a block +sub dhcpcd_plain_values +{ +my ($block, $name) = @_; +return map { $_->{'value'} } + grep { !$_->{'static'} && $_->{'name'} eq $name } + @{$block->{'members'}}; +} + +# dhcpcd_interface_lines(&iface, [&all-interfaces]) +# Converts one Webmin boot interface into dhcpcd.conf lines +sub dhcpcd_interface_lines +{ +my ($iface, $boot) = @_; +my @lines = ( "interface ".$iface->{'fullname'} ); +my @keep; +if ($iface->{'block'}) { + # Managed settings are regenerated; all other directives are preserved. + my %replace = map { $_ => 1 } + qw(ip_address routers domain_name_servers domain_search + domain_name ip6_address ip6_routers routes); + foreach my $m (@{$iface->{'block'}->{'members'}}) { + next if ($m->{'static'} && $replace{$m->{'name'}}); + next if (!$m->{'static'} && $m->{'name'} eq 'mtu'); + my $line = $m->{'static'} ? + "static $m->{'name'}=$m->{'value'}" : + $m->{'value'} eq '' ? $m->{'name'} : + "$m->{'name'} $m->{'value'}"; + push(@keep, $line); + } + } +push(@lines, @keep); + +# Virtual aliases are stored as extra static ip_address directives. +my @vifaces = grep { $_->{'name'} eq $iface->{'name'} && + $_->{'virtual'} ne '' } @$boot; +if ($iface->{'dhcp'} && !@vifaces) { + # dhcpcd uses DHCP by default when no static ip_address is set. + } +elsif ($iface->{'address'} || @vifaces) { + # Write the primary static IPv4 address first, then aliases. + my $prefix = $iface->{'netmask'} ? + &mask_to_prefix($iface->{'netmask'}) : 24; + push(@lines, "static ip_address=$iface->{'address'}/$prefix") + if ($iface->{'address'}); + foreach my $viface (@vifaces) { + my $vprefix = $viface->{'netmask'} ? + &mask_to_prefix($viface->{'netmask'}) : $prefix; + push(@lines, "static ip_address=$viface->{'address'}/$vprefix"); + } + } + +# Default gateway and static routes use separate dhcpcd directives. +if ($iface->{'gateway'}) { + push(@lines, "static routers=$iface->{'gateway'}"); + } +if ($iface->{'routes'} && @{$iface->{'routes'}}) { + my @routes; + foreach my $route (@{$iface->{'routes'}}) { + my ($dest, $gw) = split(/,/, $route, 2); + push(@routes, $dest, $gw) if ($dest && $gw); + } + push(@lines, "static routes=".join(" ", @routes)) if (@routes); + } + +# DNS servers and search domains stay inside the interface block. +if ($iface->{'nameserver'} && @{$iface->{'nameserver'}}) { + push(@lines, "static domain_name_servers=". + join(" ", @{$iface->{'nameserver'}})); + } +if ($iface->{'search'} && @{$iface->{'search'}}) { + push(@lines, "static domain_search=".join(" ", @{$iface->{'search'}})); + } + +# IPv6 static addresses and gateway are written after IPv4 settings. +for(my $i=0; $i<@{$iface->{'address6'}}; $i++) { + push(@lines, "static ip6_address=$iface->{'address6'}->[$i]/". + ($iface->{'netmask6'}->[$i] || 64)); + } +if ($iface->{'gateway6'}) { + push(@lines, "static ip6_routers=$iface->{'gateway6'}"); + } + +# MTU is a plain dhcpcd directive, not a static key. +if ($iface->{'mtu'}) { + push(@lines, "mtu $iface->{'mtu'}"); + } +return @lines; +} + +# valid_dhcpcd_route_dest(destination) +# Returns true if a static IPv4 route destination is valid +sub valid_dhcpcd_route_dest +{ +my ($dest) = @_; +return 0 if ($dest eq ''); +my ($addr, $prefix) = split(/\//, $dest, 2); +return 0 if (!&check_ipaddress($addr)); +return !defined($prefix) || $prefix =~ /^\d+$/ && $prefix >= 0 && + $prefix <= 32; +} + +# dhcpcd_implicit_interfaces(&config, [&explicit-interfaces]) +# Returns DHCP interfaces managed by dhcpcd without explicit blocks +sub dhcpcd_implicit_interfaces +{ +my ($cfg, $explicit) = @_; +return ( ) if (!&dhcpcd_should_synthesize_implicit()); + +# Do not synthesize rows for names already represented by interface blocks. +my %explicit = map { $_->{'fullname'}, 1 } @$explicit; +my @rv; +foreach my $active (&dhcpcd_active_interfaces()) { + my $name = $active->{'fullname'} || $active->{'name'}; + + # Only real, non-loopback interfaces can be implicit dhcpcd rows. + next if (!$name || $name eq "lo" || $active->{'virtual'} ne ""); + next if ($explicit{$name}); + next if (!&dhcpcd_interface_managed_by_default($name, $cfg)); + + # Limit this to physical and wireless interfaces, not bridges/tunnels. + my $type = &iface_type($name); + next if ($type !~ /Ethernet|Wireless/); + push(@rv, { 'name' => $name, + 'fullname' => $name, + 'virtual' => '', + 'file' => $dhcpcd_config, + 'edit' => 1, + 'up' => 1, + 'dhcp' => 1, + 'implicit' => 1, + 'address6' => [ ], + 'netmask6' => [ ] }); + } +return @rv; +} + +# dhcpcd_should_synthesize_implicit() +# Returns true if implicit default-DHCP interfaces should be shown +sub dhcpcd_should_synthesize_implicit +{ +return $dhcpcd_synthesize_implicit + if (defined($dhcpcd_synthesize_implicit)); + +# In normal use, implicit rows only make sense when dhcpcd is the service. +return defined(&net_dhcpcd_service_active) && + &net_dhcpcd_service_active(); +} + +# dhcpcd_active_interfaces() +# Returns active interfaces, with a test override for unit tests +sub dhcpcd_active_interfaces +{ +return @$dhcpcd_test_active_interfaces + if (ref($dhcpcd_test_active_interfaces)); +return &active_interfaces(1); +} + +# dhcpcd_interface_managed_by_default(name, &config) +# Returns true if global allow/deny rules let dhcpcd manage an interface +sub dhcpcd_interface_managed_by_default +{ +my ($name, $cfg) = @_; +my ($global) = grep { $_->{'type'} eq 'global' } @$cfg; + +# dhcpcd supports global allowinterfaces and denyinterfaces filters. +my @allow = $global ? map { split(/\s+/, $_) } + &dhcpcd_plain_values($global, "allowinterfaces") : ( ); +my @deny = $global ? map { split(/\s+/, $_) } + &dhcpcd_plain_values($global, "denyinterfaces") : ( ); + +# denyinterfaces wins first; allowinterfaces narrows the default set. +return 0 if (grep { &dhcpcd_pattern_match($name, $_) } @deny); +return 1 if (!@allow); +return grep { &dhcpcd_pattern_match($name, $_) } @allow; +} + +# dhcpcd_pattern_match(name, pattern) +# Returns true if an interface name matches a dhcpcd glob pattern +sub dhcpcd_pattern_match +{ +my ($name, $pattern) = @_; +return 0 if ($pattern eq ""); + +# Convert dhcpcd-style shell globs into a safely quoted regexp. +my $re = quotemeta($pattern); +$re =~ s/\\\*/.*/g; +$re =~ s/\\\?/./g; +return $name =~ /^$re$/; +} + +# dhcpcd_add_global_word(&lines, directive, word) +# Adds one word to a global dhcpcd directive before any interface block +sub dhcpcd_add_global_word +{ +my ($lref, $name, $word) = @_; +return if ($word eq ""); +for(my $i=0; $i<@$lref; $i++) { + my $line = $lref->[$i]; + + # Global directives must appear before interface/profile/ssid blocks. + last if ($line =~ /^\s*(interface|profile|ssid)\s+\S+/); + my $clean = $line; + $clean =~ s/#.*$//; + if ($clean =~ /^(\s*)\Q$name\E\s+(.*)$/) { + # Extend an existing directive without adding duplicates. + my @words = split(/\s+/, $2); + return if (grep { $_ eq $word } @words); + push(@words, $word); + $lref->[$i] = $1.$name." ".join(" ", @words); + return; + } + } + +# No matching directive existed, so add a new global directive at the top. +unshift(@$lref, "$name $word"); +} + +# dhcpcd_add_global_word_if_exists(&lines, directive, word) +# Adds one word to an existing global dhcpcd directive +sub dhcpcd_add_global_word_if_exists +{ +my ($lref, $name, $word) = @_; +return if ($word eq ""); +for(my $i=0; $i<@$lref; $i++) { + my $line = $lref->[$i]; + + # Only global allow/deny directives apply before interface blocks. + last if ($line =~ /^\s*(interface|profile|ssid)\s+\S+/); + my $clean = $line; + $clean =~ s/#.*$//; + if ($clean =~ /^(\s*)\Q$name\E\s+(.*)$/) { + my @words = split(/\s+/, $2); + + # Existing exact or glob patterns already allow this interface. + return if (grep { $_ eq $word || + &dhcpcd_pattern_match($word, $_) } @words); + push(@words, $word); + $lref->[$i] = $1.$name." ".join(" ", @words); + return; + } + } +} + +# dhcpcd_delete_global_word(&lines, directive, word) +# Removes one word from a global dhcpcd directive +sub dhcpcd_delete_global_word +{ +my ($lref, $name, $word) = @_; +return if ($word eq ""); +for(my $i=0; $i<@$lref; $i++) { + my $line = $lref->[$i]; + + # Stop before per-interface blocks so line numbers remain predictable. + last if ($line =~ /^\s*(interface|profile|ssid)\s+\S+/); + my $clean = $line; + $clean =~ s/#.*$//; + if ($clean =~ /^(\s*)\Q$name\E\s+(.*)$/) { + # Keep the directive if other words remain, otherwise remove it. + my @words = grep { $_ ne $word } split(/\s+/, $2); + if (@words) { + $lref->[$i] = $1.$name." ".join(" ", @words); + } + else { + splice(@$lref, $i, 1); + } + return; + } + } +} + +1; diff --git a/net/edit_aifc.cgi b/net/edit_aifc.cgi index 1f1424de0..9c8198b21 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 "") || @@ -192,4 +197,3 @@ else { print &ui_form_end(\@buts); &ui_print_footer("list_ifcs.cgi?mode=active", $text{'ifcs_return'}); - diff --git a/net/lang/en b/net/lang/en index ad2a2db77..a16126dda 100644 --- a/net/lang/en +++ b/net/lang/en @@ -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 diff --git a/net/linux-lib.pl b/net/linux-lib.pl index e542187e1..06a7014b9 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'})) { @@ -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; diff --git a/net/net-detect.pl b/net/net-detect.pl index c87ded6d6..3a021c45a 100644 --- a/net/net-detect.pl +++ b/net/net-detect.pl @@ -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 ) { 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; - diff --git a/net/save_aifc.cgi b/net/save_aifc.cgi index 9fe9ecc65..91a1be39f 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'}))); @@ -191,4 +199,3 @@ else { "aifc", $a->{'fullname'}, $a); } &redirect("list_ifcs.cgi?mode=active"); - diff --git a/net/save_bifc.cgi b/net/save_bifc.cgi index 3cd814542..b212a43cf 100755 --- a/net/save_bifc.cgi +++ b/net/save_bifc.cgi @@ -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("
$err
"); @@ -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("
$err
"); + } &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"); - diff --git a/net/save_dns.cgi b/net/save_dns.cgi index 876bb50be..617b4d72a 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"); @@ -147,4 +160,3 @@ if (&foreign_installed("postfix") && $in{'hostname'} ne $old_hostname) { &webmin_log("dns", undef, undef, \%in); &redirect(""); - diff --git a/net/t/run-tests.t b/net/t/run-tests.t index 21f936e3e..546741f87 100644 --- a/net/t/run-tests.t +++ b/net/t/run-tests.t @@ -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 '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 '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();