diff --git a/net/apply.cgi b/net/apply.cgi index c142cae6b..909f5d031 100755 --- a/net/apply.cgi +++ b/net/apply.cgi @@ -4,8 +4,9 @@ require './net-lib.pl'; $access{'apply'} || &error($text{'apply_ecannot'}); -&apply_network(); +&error_setup($text{'apply_err'}); +$err = &apply_network(); +$err && &error("
".&html_escape($err)."
"); sleep(1); &webmin_log("apply"); &redirect(""); - diff --git a/net/lang/en b/net/lang/en index 145f4d308..ad2a2db77 100644 --- a/net/lang/en +++ b/net/lang/en @@ -1,6 +1,7 @@ index_title=Network Configuration index_return=network configuration index_apply=Apply Configuration +apply_err=Failed to apply network configuration index_applydesc=Click to apply the boot-time interface and routing settings as if the system were rebooted. ⚠ Misconfiguration may make your system unreachable over the network and cut off Webmin access. Editing hostname and DNS settings doesn’t require this step. index_delete1=De-Activate Selected Interfaces index_delete2=Delete Selected Interfaces diff --git a/net/linux-lib.pl b/net/linux-lib.pl index 86adbc167..e542187e1 100755 --- a/net/linux-lib.pl +++ b/net/linux-lib.pl @@ -968,7 +968,8 @@ if ($use_suse_dns) { # Update resolv.conf from network interfaces config if ($need_apply) { - &apply_network(); + my $err = &apply_network(); + &error("
".&html_escape($err)."
") if ($err); } } @@ -1007,4 +1008,3 @@ else { } 1; - diff --git a/net/netplan-lib.pl b/net/netplan-lib.pl index 58050bd62..857d5b0c7 100644 --- a/net/netplan-lib.pl +++ b/net/netplan-lib.pl @@ -165,6 +165,30 @@ foreach my $f (glob("$netplan_dir/*.yaml")) { return @rv; } +# yaml_directive_indent(&directive, default-spaces) +# Returns the indentation to use for an existing or new YAML directive. +sub yaml_directive_indent +{ +my ($yaml, $def) = @_; +my $indent = $yaml && defined($yaml->{'indent'}) ? $yaml->{'indent'} : $def; +return " " x $indent; +} + +# yaml_child_indent(&directive, default-spaces) +# Returns the indentation step used by an existing YAML directive's children. +sub yaml_child_indent +{ +my ($yaml, $def) = @_; +if ($yaml && $yaml->{'members'}) { + foreach my $m (@{$yaml->{'members'}}) { + if (defined($m->{'indent'}) && $m->{'indent'} > $yaml->{'indent'}) { + return $m->{'indent'} - $yaml->{'indent'}; + } + } + } +return $def; +} + # save_interface(&details, [&all-interfaces]) # Create or update a boot-time interface sub save_interface @@ -190,22 +214,25 @@ if ($iface->{'virtual'} ne '') { } else { # Build interface config lines - my $id = " " x 8; + my $id = &yaml_directive_indent($iface->{'yaml'}, 8); + my $step = &yaml_child_indent($iface->{'yaml'}, 4); + my $cid = $id.(" " x $step); + my $gid = $cid.(" " x $step); my @lines; push(@lines, $id.$iface->{'fullname'}.":"); my @addrs; if (!$iface->{'up'}) { - push(@lines, $id." "."optional: true"); + push(@lines, $cid."optional: true"); } if ($iface->{'dhcp'}) { - push(@lines, $id." "."dhcp4: true"); + push(@lines, $cid."dhcp4: true"); } elsif ($iface->{'address'}) { push(@addrs, $iface->{'address'}."/". &mask_to_prefix($iface->{'netmask'})); } if ($iface->{'auto6'}) { - push(@lines, $id." "."dhcp6: true"); + push(@lines, $cid."dhcp6: true"); } for(my $i=0; $i<@{$iface->{'address6'}}; $i++) { push(@addrs, $iface->{'address6'}->[$i]."/". @@ -218,37 +245,37 @@ else { } } if (@addrs) { - push(@lines, $id." "."addresses: [". + push(@lines, $cid."addresses: [". &join_addr_list(@addrs)."]"); } if ($iface->{'gateway'}) { - push(@lines, $id." "."gateway4: ".$iface->{'gateway'}); + push(@lines, $cid."gateway4: ".$iface->{'gateway'}); } if ($iface->{'gateway6'}) { - push(@lines, $id." "."gateway6: ".$iface->{'gateway6'}); + push(@lines, $cid."gateway6: ".$iface->{'gateway6'}); } if ($iface->{'nameserver'}) { - push(@lines, $id." "."nameservers:"); - push(@lines, $id." "."addresses: [". + push(@lines, $cid."nameservers:"); + push(@lines, $gid."addresses: [". &join_addr_list(@{$iface->{'nameserver'}})."]"); if ($iface->{'search'}) { - push(@lines, $id." "."search: [". + push(@lines, $gid."search: [". &join_addr_list(@{$iface->{'search'}})."]"); } } if ($iface->{'ether'}) { - push(@lines, $id." "."macaddress: ".$iface->{'ether'}); + push(@lines, $cid."macaddress: ".$iface->{'ether'}); } if ($iface->{'routes'}) { - push(@lines, &yaml_lines($iface->{'routes'}, $id." ")); + push(@lines, &yaml_lines($iface->{'routes'}, $cid, $step)); } if ($iface->{'bridgeto'}) { - push(@lines, $id." "."interfaces: [".$iface->{'bridgeto'}."]"); - push(@lines, $id." "."parameters:"); - push(@lines, $id." "."stp: ". + push(@lines, $cid."interfaces: [".$iface->{'bridgeto'}."]"); + push(@lines, $cid."parameters:"); + push(@lines, $gid."stp: ". ($iface->{'bridgestp'} eq 'on' ? 'true' : 'false')); if ($iface->{'bridgefd'}) { - push(@lines, $id." "."forward-delay: ". + push(@lines, $gid."forward-delay: ". $iface->{'bridgefd'}); } } @@ -259,7 +286,7 @@ else { if ($iface->{'yaml'}) { foreach my $y (@{$iface->{'yaml'}->{'members'}}) { next if (&indexof($y->{'name'}, @poss) >= 0); - push(@lines, &yaml_lines($y, $id." ")); + push(@lines, &yaml_lines($y, $cid, $step)); } } @@ -610,7 +637,29 @@ return ( "/etc/hostname", "/etc/HOSTNAME", "/etc/mailname" ); # Apply the interface and routing settings sub apply_network { -&system_logged("(cd / ; netplan apply) >/dev/null 2>&1"); +my $err = &validate_netplan_config(); +return $err if ($err); +my $out; +my $cmd = "(cd / && ".&netplan_command()." apply)"; +my $rv = &execute_command_logged($cmd, undef, \$out, \$out); +return $rv ? $out || "netplan apply failed" : undef; +} + +# validate_netplan_config() +# Check that the current YAML can be rendered before applying it. +sub validate_netplan_config +{ +my $out; +my $cmd = "(cd / && ".&netplan_command()." generate)"; +my $rv = &execute_command_logged($cmd, undef, \$out, \$out); +return $rv ? $out || "netplan generate failed" : undef; +} + +# netplan_command() +# Returns the netplan command path. +sub netplan_command +{ +return &has_command("netplan") || "netplan"; } # get_default_gateway() @@ -728,13 +777,49 @@ sub os_save_dns_config { my ($conf) = @_; my @boot = &boot_interfaces(); -my @fix = grep { $_->{'nameserver'} } @boot; -@fix = @boot if (!@fix); -foreach my $iface (@fix) { - $iface->{'nameserver'} = $conf->{'nameserver'}; - $iface->{'search'} = $conf->{'domain'}; - &save_interface($iface); +my $newns = @{$conf->{'nameserver'} || []} ? $conf->{'nameserver'} : undef; +my $newsearch = @{$conf->{'domain'} || []} ? $conf->{'domain'} : undef; +my @fix = grep { (!defined($_->{'virtual'}) || $_->{'virtual'} eq '') && + $_->{'nameserver'} } @boot; +if (!@fix) { + @fix = grep { (!defined($_->{'virtual'}) || $_->{'virtual'} eq '') && + $_->{'name'} !~ /^lo/ } @boot; + @fix = @fix ? ( $fix[0] ) : ( ); } +my $need_apply = 0; +foreach my $iface (@fix) { + next if (&same_list($iface->{'nameserver'}, $newns) && + &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); +} + +# same_list(&list1, &list2) +# Returns 1 if two optional array refs contain the same values. +sub 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_yaml_file(file) @@ -754,7 +839,8 @@ foreach my $origl (@$lref) { if ($l =~ /^(\s*)(\S+):\s*(.*)/) { # Name and possibly value my $i = length($1); - if ($i >= $lastdir->{'indent'} + 2 && + if ($lastdir && + $i >= $lastdir->{'indent'} + 2 && ref($lastdir->{'value'}) eq 'ARRAY' && @{$lastdir->{'value'}} && ref($lastdir->{'value'}->[0]) eq 'HASH') { @@ -844,11 +930,12 @@ foreach my $c (@$conf) { } } -# yaml_lines(&directive, indent-string) +# yaml_lines(&directive, indent-string, [indent-step]) # Converts a YAML directive into text lines sub yaml_lines { -my ($yaml, $id) = @_; +my ($yaml, $id, $step) = @_; +$step ||= 4; my @rv; my $v = $id.$yaml->{'name'}.":"; if (ref($yaml->{'value'}) eq 'ARRAY') { @@ -878,7 +965,7 @@ else { } if ($yaml->{'members'}) { foreach my $m (@{$yaml->{'members'}}) { - push(@rv, &yaml_lines($m, $id." ")); + push(@rv, &yaml_lines($m, $id.(" " x $step), $step)); } } return @rv; diff --git a/net/t/run-tests.t b/net/t/run-tests.t new file mode 100644 index 000000000..8012538ac --- /dev/null +++ b/net/t/run-tests.t @@ -0,0 +1,170 @@ +#!/usr/bin/perl +use strict; +use warnings; +use Test::More; +use Cwd qw(abs_path); +use File::Basename qw(dirname); +use File::Temp qw(tempdir); + +my $root = abs_path(dirname(__FILE__)."/../..") or die "rootdir: $!"; +my $tmp = tempdir(CLEANUP => 1); +my %file_cache; +my @commands; +my %command_status; +my %command_output; + +sub write_text +{ +my ($file, $text) = @_; +open(my $fh, ">", $file) || die "write $file: $!"; +print $fh $text; +close($fh) || die "close $file: $!"; +delete($file_cache{$file}); +} + +sub read_text +{ +my ($file) = @_; +open(my $fh, "<", $file) || die "read $file: $!"; +local $/ = undef; +my $text = <$fh>; +close($fh) || die "close $file: $!"; +return $text; +} + +sub read_file_lines +{ +my ($file) = @_; +if (!exists($file_cache{$file})) { + open(my $fh, "<", $file) || die "read_file_lines $file: $!"; + my @lines = <$fh>; + close($fh) || die "close $file: $!"; + chomp(@lines); + $file_cache{$file} = \@lines; + } +return $file_cache{$file}; +} + +sub flush_file_lines +{ +my ($file) = @_; +open(my $fh, ">", $file) || die "flush $file: $!"; +foreach my $line (@{$file_cache{$file}}) { + print $fh $line, "\n"; + } +close($fh) || die "close $file: $!"; +} + +sub lock_file { return 1; } +sub unlock_file { return 1; } +sub error { die join("", @_), "\n"; } +sub has_command { return $_[0] eq "netplan" ? "/usr/sbin/netplan" : undef; } +sub execute_command_logged +{ +my ($cmd, undef, $stdout, $stderr) = @_; +push(@commands, $cmd); +my $out = $command_output{$cmd} || ""; +$$stdout = $out if (ref($stdout)); +$$stderr = $out if (ref($stderr) && $stderr ne $stdout); +return $command_status{$cmd} || 0; +} +sub check_ipaddress { return $_[0] =~ /^\d+\.\d+\.\d+\.\d+$/; } +sub check_ip6address { return $_[0] =~ /:/; } +sub check_ipaddress_any { return &check_ipaddress($_[0]) || &check_ip6address($_[0]); } +sub mask_to_prefix { return $_[0] eq "255.255.255.0" ? 24 : $_[0]; } +sub prefix_to_mask { return $_[0] == 24 ? "255.255.255.0" : $_[0]; } +sub indexof +{ +my ($needle, @haystack) = @_; +for(my $i=0; $i<@haystack; $i++) { + return $i if ($haystack[$i] eq $needle); + } +return -1; +} + +unshift(@INC, "$root/net", $root); +do "$root/net/netplan-lib.pl" || die "netplan-lib.pl: $@ $!"; + +{ + no warnings 'once'; + $main::netplan_dir = $tmp; +} + +my $netplan = "$tmp/50-cloud-init.yaml"; +write_text($netplan, <<'YAML'); +network: + version: 2 + ethernets: + eth0: + dhcp4: true + nameservers: + addresses: + - 1.1.1.1 + search: + - example.com + eth1: + dhcp4: true +YAML + +my @boot = main::boot_interfaces(); +is(scalar(grep { !defined($_->{'virtual'}) || $_->{'virtual'} eq '' } @boot), + 2, "parsed two boot interfaces"); +my ($eth0) = grep { $_->{'fullname'} eq "eth0" } @boot; +$eth0->{'nameserver'} = [ "127.0.0.1", "2001:4860:4860::8888" ]; +$eth0->{'search'} = [ "example.com" ]; +main::save_interface($eth0, \@boot); + +my $saved = read_text($netplan); +like($saved, qr/^ eth0:\n dhcp4: true\n nameservers:\n addresses: \[127\.0\.0\.1, '2001:4860:4860::8888'\]\n search: \[example\.com\]/m, + "save_interface preserves existing two-space Netplan hierarchy"); +like($saved, qr/^ eth1:\n dhcp4: true/m, + "untouched sibling interface keeps matching indentation"); +unlike($saved, qr/^ eth0:/m, + "rewritten interface is not moved to an eight-space sibling indent"); + +write_text($netplan, <<'YAML'); +network: + version: 2 + ethernets: + eth0: + dhcp4: true + nameservers: + addresses: + - 1.1.1.1 + eth1: + dhcp4: true +YAML +@boot = main::boot_interfaces(); +my ($need_apply, $generated_resolv) = + main::os_save_dns_config({ 'nameserver' => [ "127.0.0.1" ], + 'domain' => [ "example.com" ] }); +ok($need_apply, "DNS changes request a netplan apply"); +ok($generated_resolv, "Netplan DNS save reports resolv.conf as generated"); +$saved = read_text($netplan); +like($saved, qr/^ eth0:\n dhcp4: true\n nameservers:\n addresses: \[127\.0\.0\.1\]\n search: \[example\.com\]/m, + "os_save_dns_config updates existing nameservers block"); +unlike($saved, qr/eth1:\n(?:.*\n)*?\s+nameservers:/, + "os_save_dns_config does not add nameservers to every interface"); + +@commands = ( ); +%command_status = ( + "(cd / && /usr/sbin/netplan generate)" => 1, + ); +%command_output = ( + "(cd / && /usr/sbin/netplan generate)" => "bad yaml\n", + ); +is(main::apply_network(), "bad yaml\n", + "apply_network returns validation errors"); +is_deeply(\@commands, [ "(cd / && /usr/sbin/netplan generate)" ], + "apply_network skips apply when generate fails"); + +@commands = ( ); +%command_status = ( ); +%command_output = ( ); +is(main::apply_network(), undef, "apply_network applies after validation"); +is_deeply(\@commands, + [ "(cd / && /usr/sbin/netplan generate)", + "(cd / && /usr/sbin/netplan apply)" ], + "apply_network validates before applying"); + +done_testing();