Compare commits

..

1 Commits

Author SHA1 Message Date
Ilia Ross
9342671b0b Fix Netplan deletion of interfaces in shared files
This PR fixes interface deletion when multiple interfaces share a Netplan YAML file.

Each deletion looks up the interface’s current position, so earlier deletions cannot leave stale line numbers.

Unrelated interfaces and global settings are preserved. Empty device sections are removed.

Live regression tests and nine native Netplan generation scenarios passed on Ubuntu.
2026-10-29 15:17:23 +01:00
17 changed files with 242 additions and 317 deletions

View File

@@ -22,7 +22,6 @@ my $fh = "IN";
&open_tempfile($fh, ">$temp1"); &open_tempfile($fh, ">$temp1");
foreach my $k ("emailAddress", "organizationalUnitName", "organizationName", foreach my $k ("emailAddress", "organizationalUnitName", "organizationName",
"stateOrProvinceName", "countryName", "commonName") { "stateOrProvinceName", "countryName", "commonName") {
$in{$k} =~ /:|\n|\0/ && &error($text{'cert_echars'});
&print_tempfile($fh, "$k = $in{$k}\n"); &print_tempfile($fh, "$k = $in{$k}\n");
} }
$in{'key'} =~ s/\s//g; $in{'key'} =~ s/\s//g;
@@ -32,12 +31,12 @@ my $cmd = &get_ssleay();
my $ssleay = &backquote_logged("$cmd ca -spkac $temp1 -out $temp2 -config $module_config_directory/openssl.cnf -days 1095 2>&1"); my $ssleay = &backquote_logged("$cmd ca -spkac $temp1 -out $temp2 -config $module_config_directory/openssl.cnf -days 1095 2>&1");
&unlink_file($temp1); &unlink_file($temp1);
if ($?) { if ($?) {
&error("<pre>".&html_escape($ssleay)."</pre>"); &error("<pre>$ssleay</pre>");
} }
else { else {
# Display status and redirect to actual cert file # Display status and redirect to actual cert file
&ui_print_unbuffered_header(undef, $text{'cert_title'}, ""); &ui_print_unbuffered_header(undef, $text{'cert_title'}, "");
print &text('cert_done', &html_escape($in{'commonName'})),"<p>\n"; print &text('cert_done', $in{'commonName'}),"<p>\n";
print &text('cert_pickup', "cert_output.cgi?file=$temp2"),"<p>\n"; print &text('cert_pickup', "cert_output.cgi?file=$temp2"),"<p>\n";
&ui_print_footer("", $text{'index_return'}); &ui_print_footer("", $text{'index_return'});

View File

@@ -7,7 +7,7 @@ use warnings;
no warnings 'redefine'; no warnings 'redefine';
no warnings 'uninitialized'; no warnings 'uninitialized';
require './acl-lib.pl'; ## no critic require './acl-lib.pl'; ## no critic
our (%in, %text, %config, %access, $config_directory, $base_remote_user); our (%in, %text, %config, %access, $config_directory);
&ReadParse(); &ReadParse();
$access{'groups'} || &error($text{'gedit_ecannot'}); $access{'groups'} || &error($text{'gedit_ecannot'});
my $g; my $g;
@@ -95,12 +95,6 @@ print &ui_hidden_table_start($text{'edit_mods'}, "width=100%", 2, "mods");
# Show available modules, under categories # Show available modules, under categories
my @mlist = &list_module_infos(); my @mlist = &list_module_infos();
my %has = map { $_, 1 } @{$group{'modules'}}; my %has = map { $_, 1 } @{$group{'modules'}};
# Disable modules that this administrator cannot grant or remove.
my $me = $access{'mode'} == 1 ? &get_user($base_remote_user) : undef;
@mcan = $access{'mode'} == 1 ? @{$me->{'modules'} || []} :
$access{'mode'} == 2 ? split(/\s+/, $access{'mods'}) :
map { $_->{'dir'} } @mlist;
my %mcan = map { $_, 1 } @mcan;
my @links = ( &select_all_link("mod", 0, $text{'edit_selall'}), my @links = ( &select_all_link("mod", 0, $text{'edit_selall'}),
&select_invert_link("mod", 0, $text{'edit_invert'}) ); &select_invert_link("mod", 0, $text{'edit_invert'}) );
my @cats = &unique(map { $_->{'category'} || "" } @mlist); my @cats = &unique(map { $_->{'category'} || "" } @mlist);
@@ -125,8 +119,7 @@ foreach my $c (sort { $b cmp $a } @cats) {
else { else {
$label = $m->{'desc'}; $label = $m->{'desc'};
} }
push(@grid, &ui_checkbox("mod", $md, $label, $has{$md}, push(@grid, &ui_checkbox("mod", $md, $label,$has{$md}));
undef, !$mcan{$md}));
} }
$grids .= &ui_grid_table(\@grid, 2, 100, [ "width=50%", "width=50%" ]); $grids .= &ui_grid_table(\@grid, 2, 100, [ "width=50%", "width=50%" ]);
} }

View File

@@ -175,7 +175,6 @@ cert_ekey=A new SSL key was not submitted by your browser - maybe it does not su
cert_eca=Failed to setup certificate authority : $1 cert_eca=Failed to setup certificate authority : $1
cert_already=Warning - you are already using the certificate $1. cert_already=Warning - you are already using the certificate $1.
cert_etempdir=Invalid certificate file cert_etempdir=Invalid certificate file
cert_echars=Invalid characters in cert field
acl_title=Module Access Control acl_title=Module Access Control
acl_title2=For $1 in $2 acl_title2=For $1 in $2

View File

@@ -7,7 +7,7 @@ use warnings;
no warnings 'redefine'; no warnings 'redefine';
no warnings 'uninitialized'; no warnings 'uninitialized';
require './acl-lib.pl'; ## no critic require './acl-lib.pl'; ## no critic
our (%in, %text, %config, %access, $config_directory, $base_remote_user); our (%in, %text, %config, %access, $config_directory);
&ReadParse(); &ReadParse();
$access{'groups'} || &error($text{'gedit_ecannot'}); $access{'groups'} || &error($text{'gedit_ecannot'});
@@ -21,10 +21,6 @@ elsif ($in{'but_delete'}) {
exit; exit;
} }
# Find logged-in webmin user
my @ulist = &list_users();
my ($me) = grep { $_->{'name'} eq $base_remote_user } @ulist;
my (%group, $old); my (%group, $old);
if ($in{'old'}) { if ($in{'old'}) {
# Get the original group # Get the original group
@@ -46,21 +42,6 @@ if (!$in{'old'} || $in{'old'} ne $in{'name'}) {
} }
$in{'desc'} !~ /:/ || &error($text{'gsave_edesc'}); $in{'desc'} !~ /:/ || &error($text{'gsave_edesc'});
# Check grantable modules before changing parent group membership.
$access{'mode'} == 1 && !$me && &error($text{'edit_egone'});
my @mcan = $access{'mode'} == 1 ? @{$me->{'modules'} || []} :
$access{'mode'} == 2 ? split(/\s+/, $access{'mods'}) :
&list_modules();
my %mcan = map { $_, 1 } @mcan;
# Keep existing modules that this administrator cannot change.
my %keep = map { $_, 1 } grep { !$mcan{$_} }
($old ? @{$old->{'modules'}} : ());
my @mods = split(/\0/, $in{'mod'});
foreach my $m (@mods) {
$mcan{$m} || $keep{$m} || &error(&text('save_emod', &html_escape($m)));
}
@mods = &unique(@mods, keys %keep);
# Find the current parent group # Find the current parent group
my $oldgroup; my $oldgroup;
if ($in{'old'}) { if ($in{'old'}) {
@@ -99,6 +80,9 @@ if (defined($in{'group'})) {
} }
} }
# Work out group modules
my @mods = split(/\0/, $in{'mod'});
if ($oldgroup) { if ($oldgroup) {
# Remove modules from the old parent group # Remove modules from the old parent group
@mods = grep { &indexof($_, @{$oldgroup->{'modules'}}) < 0 } @mods; @mods = grep { &indexof($_, @{$oldgroup->{'modules'}}) < 0 } @mods;

View File

@@ -103,7 +103,12 @@ if ($in{'temp'}) {
# Find logged-in webmin user # Find logged-in webmin user
my @ulist = &list_users(); my @ulist = &list_users();
my ($me) = grep { $_->{'name'} eq $base_remote_user } @ulist; my $me;
foreach my $u (@ulist) {
if ($u->{'name'} eq $base_remote_user) {
$me = $u;
}
}
# Find the current group # Find the current group
my $oldgroup = $in{'old'} ? &get_users_group($in{'old'}) : undef; my $oldgroup = $in{'old'} ? &get_users_group($in{'old'}) : undef;
@@ -153,7 +158,7 @@ my %mcan = map { $_, 1 } @mcan;
my @mods = split(/\0/, $in{'mod'}); my @mods = split(/\0/, $in{'mod'});
foreach my $m (@mods) { foreach my $m (@mods) {
$mcan{$m} || &error(&text('save_emod', &html_escape($m))); $mcan{$m} || &error(&text('save_emod', $m));
} }
if ($in{'old'}) { if ($in{'old'}) {
# Add modules that this user already has, but were not # Add modules that this user already has, but were not

View File

@@ -1474,25 +1474,6 @@ subtest 'save_user.cgi create privilege' => sub {
delete_user('admin'); delete_user('admin');
}; };
subtest 'save_user.cgi rejected module escaping' => sub {
_reset_fixture();
_seed_user_acl('admin', { create => 1, mode => 2, mods => 'proc' });
create_user({ name => 'admin', pass => 'x', modules => ['acl', 'proc'] });
# Ordinary and hostile module names must remain text in the error page.
for my $module ('cron', '<script>alert(1)</script>') {
my $r = run_cgi('save_user.cgi', {
name => 'newbie', pass_def => 1, mod => $module,
logouttime_def => 1, minsize_def => 1 });
my $expected = text('save_emod', html_escape($module));
like($r->{out}, qr/\Q$expected\E/, 'rejected module is escaped');
unlike($r->{out}, qr/<script>alert\(1\)<\/script>/,
'module error cannot inject a script');
_clear_caches();
ok(!get_user('newbie'), 'rejected module does not create an account');
}
};
# Unix conversion must not bypass the privileged-name guard in save_user.cgi. # Unix conversion must not bypass the privileged-name guard in save_user.cgi.
# An ACL-capable administrator can still deliberately convert such a user. # An ACL-capable administrator can still deliberately convert such a user.
subtest 'convert.cgi privileged username gate' => sub { subtest 'convert.cgi privileged username gate' => sub {
@@ -1669,125 +1650,6 @@ subtest 'save_group.cgi name validation' => sub {
ok(!$g, 'group with colon-bearing description not created'); ok(!$g, 'group with colon-bearing description not created');
}; };
# Group module limits must reject requests before any membership is changed.
subtest 'save_group.cgi module limits' => sub {
_reset_fixture();
create_user({ name => 'admin', pass => 'x', modules => ['acl', 'proc'] });
create_group({ name => 'parent', members => [], modules => ['proc'] });
_seed_user_acl('admin', { mode => 2, mods => 'proc', groups => 1,
gassign => '*' });
my $r = run_cgi('save_group.cgi', {
name => 'child', desc => 'd', group => 'parent', mod => ['proc', 'cron'] });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
'explicit module limit rejects an unauthorized grant');
_clear_caches();
ok(!get_group('child'), 'rejected group is not created');
is_deeply(get_group('parent')->{members}, [],
'rejected save does not change parent membership');
$r = run_cgi('save_group.cgi', {
name => 'child', desc => 'd', group => 'parent', mod => 'proc' });
is($r->{status}, 0, 'allowed group creation exits successfully');
_clear_caches();
is_deeply(get_group('child')->{modules}, ['proc'], 'allowed module is saved');
is_deeply(get_group('parent')->{members}, ['@child'],
'successful save updates parent membership');
$r = run_cgi('save_group.cgi', {
old => 'child', name => 'child', desc => 'changed', group => '', mod => 'cron' });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
'editing an existing group cannot grant an unauthorized module');
_clear_caches();
is_deeply(get_group('parent')->{members}, ['@child'],
'rejected edit does not detach the existing group');
my $payload = '<script>alert(1)</script>';
$r = run_cgi('save_group.cgi', { name => 'badmod', mod => $payload });
like($r->{out}, qr/&lt;script&gt;/, 'rejected module name is escaped');
unlike($r->{out}, qr/\Q$payload\E/, 'module error cannot inject HTML');
# Mode 1 uses the effective user's modules, including mapped logins.
_seed_user_acl('admin', { mode => 1, groups => 1 });
$r = run_cgi('save_group.cgi', { name => 'ownmods', mod => 'proc' },
user => 'mapped-user', env => { BASE_REMOTE_USER => 'admin' });
_clear_caches();
is_deeply(get_group('ownmods')->{modules}, ['proc'],
'mapped login uses the base user module list');
$r = run_cgi('save_group.cgi', { name => 'denied', mod => 'cron' });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
'mode 1 rejects modules the base user does not hold');
delete_user('admin');
_seed_user_acl('admin', { mode => 1, groups => 1 });
$r = run_cgi('save_group.cgi', { name => 'missing', mod => 'proc' });
like($r->{out}, qr/\Q$text{'edit_egone'}\E/, 'missing user returns a normal error');
unlike($r->{err}, qr/undefined value|compilation errors/,
'missing user does not cause a Perl exception');
_clear_caches();
ok(!get_group('missing'), 'missing user cannot create a group');
# Unrestricted mode does not require the mode-1 user record.
_seed_user_acl('admin', { mode => 0, groups => 1 });
$r = run_cgi('save_group.cgi', { name => 'unrestricted', mod => 'cron' });
_clear_caches();
is_deeply(get_group('unrestricted')->{modules}, ['cron'],
'unrestricted module grants remain supported');
};
# Editing unrelated fields must retain modules outside the admin's grant list.
subtest 'existing group module preservation' => sub {
for my $mode (1, 2, 0) {
_reset_fixture();
create_user({ name => 'admin', pass => 'x', modules => ['acl', 'proc'] });
create_user({ name => 'member', pass => 'x', modules => ['proc', 'cron'] });
create_group({ name => 'existing', members => ['member'],
modules => ['proc', 'cron'], desc => 'before' });
_seed_user_acl('admin', { mode => $mode, mods => 'proc', groups => 1 });
my $r = run_cgi('edit_group.cgi', { group => 'existing' });
my ($checkbox) = $r->{out} =~ /(<input\b[^>]*value="cron"[^>]*>)/;
ok($checkbox && $checkbox =~ /checked/, "mode $mode shows existing cron access");
is($checkbox && $checkbox =~ /disabled/ ? 1 : 0, $mode ? 1 : 0,
"mode $mode disables only restricted modules");
# Disabled controls are omitted by the browser.
$r = run_cgi('save_group.cgi', {
old => 'existing', name => 'existing', desc => 'after', mod => 'proc' });
_clear_caches();
my $group = get_group('existing');
is($group->{desc}, 'after', "mode $mode permits description edits");
my @expected = $mode ? qw(cron proc) : qw(proc);
is_deeply([sort @{$group->{modules}}], \@expected,
"mode $mode preserves restricted modules but allows authorized removal");
is_deeply([sort @{get_user('member')->{modules}}], \@expected,
"mode $mode preserves the correct member access");
next if !$mode;
# An old form can still submit the existing restricted selection.
$r = run_cgi('save_group.cgi', {
old => 'existing', name => 'existing', desc => 'old form',
mod => ['proc', 'cron'] });
_clear_caches();
is(get_group('existing')->{desc}, 'old form',
"mode $mode accepts an unchanged restricted selection");
$r = run_cgi('save_group.cgi', {
old => 'existing', name => 'existing', desc => 'forged',
mod => ['proc', 'cron', 'webmin'] });
like($r->{out}, qr/You cannot grant access to module 'webmin'/,
"mode $mode rejects a new unauthorized module");
_clear_caches();
is(get_group('existing')->{desc}, 'old form',
"mode $mode rejects before writing changes");
$r = run_cgi('save_group.cgi', {
name => 'cloned', clone => 'existing', mod => ['proc', 'cron'] });
like($r->{out}, qr/You cannot grant access to module 'cron'/,
"mode $mode cannot grant restricted modules by cloning");
}
};
subtest 'save_user.cgi self-lockout guard' => sub { subtest 'save_user.cgi self-lockout guard' => sub {
# Editing yourself out of the acl module is refused — otherwise an # Editing yourself out of the acl module is refused — otherwise an
# admin could lock themselves out of Webmin entirely. # admin could lock themselves out of Webmin entirely.

View File

@@ -1 +0,0 @@
desc_fr=Gestionnaire de Fichiers

View File

@@ -20,7 +20,6 @@ category_net=Réseau
category_syslet=Syslets category_syslet=Syslets
category_cluster=Cluster category_cluster=Cluster
category_info=Information category_info=Information
category_=Outils
longcategory_servers=Modules pour les configurations web, email, FTP et autres serveurs longcategory_servers=Modules pour les configurations web, email, FTP et autres serveurs
longcategory_hardware=Modules pour les imprimantes, disques et autres configurations de matériel longcategory_hardware=Modules pour les imprimantes, disques et autres configurations de matériel

View File

@@ -904,9 +904,6 @@ sub device_input
{ {
local (%used, $vg, $pv, $d, $p); local (%used, $vg, $pv, $d, $p);
# Dropdown labels need plain text sizes without theme HTML.
local $main::theme_forbid_nice_size = 1;
# Find partitions that are part of an LVM # Find partitions that are part of an LVM
foreach $vg (&list_volume_groups()) { foreach $vg (&list_volume_groups()) {
foreach $pv (&list_physical_volumes($vg->{'name'})) { foreach $pv (&list_physical_volumes($vg->{'name'})) {

View File

@@ -354,13 +354,32 @@ if ($iface->{'virtual'} ne '') {
&save_interface($parent, $boot); &save_interface($parent, $boot);
} }
else { else {
# Delete all the lines # Find current lines because earlier deletions may have shifted them.
&lock_file($iface->{'file'}); &lock_file($iface->{'file'});
&unflush_file_lines($iface->{'file'});
my $yaml = &read_yaml_file($iface->{'file'});
my @network = grep { $_->{'name'} eq 'network' } @$yaml;
my @groups = grep { $_->{'name'} eq 'ethernets' ||
$_->{'name'} eq 'bridges' }
map { @{$_->{'members'}} } @network;
my $entry;
foreach my $g (@groups) {
my ($e) = grep { $_->{'name'} eq $iface->{'fullname'} }
@{$g->{'members'}};
next if (!$e);
# Remove the group as well when deleting its last interface.
$entry = @{$g->{'members'}} == 1 ? $g : $e;
last;
}
$entry || &error("No interface named $iface->{'fullname'} exists");
# Delete the interface without changing neighboring definitions.
my $lref = &read_file_lines($iface->{'file'}); my $lref = &read_file_lines($iface->{'file'});
splice(@$lref, $iface->{'line'}, splice(@$lref, $entry->{'line'},
$iface->{'eline'} - $iface->{'line'} + 1); $entry->{'eline'} - $entry->{'line'} + 1);
&flush_file_lines($iface->{'file'}); &flush_file_lines($iface->{'file'});
if (&is_yaml_empty($iface->{'file'})) { if (&is_yaml_empty($iface->{'file'})) {
# Discard the file only when no settings or interfaces remain.
&unlink_file($iface->{'file'}); &unlink_file($iface->{'file'});
} }
&unlock_file($iface->{'file'}); &unlock_file($iface->{'file'});
@@ -368,22 +387,22 @@ else {
} }
# is_yaml_empty(file) # is_yaml_empty(file)
# Return 1 if a YAML file contains only network and ethernets line, with no # Returns 1 if only the version and empty Ethernet or bridge groups remain
# other interfaces
sub is_yaml_empty sub is_yaml_empty
{ {
my ($file) = @_; my ($file) = @_;
my $yaml = &read_yaml_file($file); my $yaml = &read_yaml_file($file);
return 1 if (!$yaml); return 1 if (!$yaml);
my @rest = grep { $_->{'name'} ne 'network' } @$yaml;
return 0 if (@rest);
foreach my $n (@$yaml) { foreach my $n (@$yaml) {
my @rest = grep { $_->{'name'} ne 'ethernets' && # Preserve unknown directives and inline network settings.
$_->{'name'} ne 'bridges' } return 0 if ($n->{'name'} ne 'network' ||
@{$network->{'members'}}; ($n->{'value'} ne '' && $n->{'value'} ne '{}'));
return 0 if (@rest); # Only the version and empty device groups are safe to discard.
foreach my $ens (@{$network->{'members'}}) { foreach my $ens (@{$n->{'members'}}) {
return 0 if (@{$ens->{'members'}}); next if ($ens->{'name'} eq 'version');
return 0 if (($ens->{'name'} ne 'ethernets' &&
$ens->{'name'} ne 'bridges') || @{$ens->{'members'}} ||
($ens->{'value'} ne '' && $ens->{'value'} ne '{}'));
} }
} }
return 1; return 1;

View File

@@ -5,7 +5,7 @@
require './proc-lib.pl'; require './proc-lib.pl';
&ReadParse(); &ReadParse();
&switch_acl_uid(); &switch_acl_uid();
&error_setup(&text('kill_err', &html_escape($in{signal}), &html_escape($in{pid}))); &error_setup(&text('kill_err', $in{signal}, $in{pid}));
foreach $s ('KILL', 'TERM', 'HUP', 'STOP', 'CONT') { foreach $s ('KILL', 'TERM', 'HUP', 'STOP', 'CONT') {
$in{'signal'} = $s if ($in{$s}); $in{'signal'} = $s if ($in{$s});
} }

View File

@@ -14,10 +14,10 @@ foreach $s ('KILL', 'TERM', 'HUP', 'STOP', 'CONT') {
@pinfo = &list_processes(@pidlist); @pinfo = &list_processes(@pidlist);
for($i=0; $i<@pidlist; $i++) { for($i=0; $i<@pidlist; $i++) {
$in{"args$i"} = $pinfo[$i]->{'args'}; $in{"args$i"} = $pinfo[$i]->{'args'};
print "$text{'pid'} <tt>".&html_escape($pidlist[$i])."</tt> ... \n"; print "$text{'pid'} <tt>$pidlist[$i]</tt> ... \n";
if (&can_edit_process($pinfo[$i]->{'user'})) { if (&can_edit_process($pinfo[$i]->{'user'})) {
if (&kill_logged($in{signal}, $pidlist[$i])) { if (&kill_logged($in{signal}, $pidlist[$i])) {
print "SIG".&html_escape($in{'signal'})." $text{'kill_sent'}<br>\n"; print "SIG$in{signal} $text{'kill_sent'}<br>\n";
} }
else { else {
print "$!<br>\n"; print "$!<br>\n";
@@ -29,6 +29,5 @@ for($i=0; $i<@pidlist; $i++) {
} }
&webmin_log("kill", undef, undef, \%in); &webmin_log("kill", undef, undef, \%in);
print "<p>\n"; print "<p>\n";
&ui_print_footer("index_search.cgi?".&html_escape($in{'args'}), &ui_print_footer("index_search.cgi?$in{'args'}", $text{'search_return'});
$text{'search_return'});

View File

@@ -523,8 +523,8 @@ close(PKG);
while(<PKG>) { while(<PKG>) {
s/\r|\n//g; s/\r|\n//g;
next if (/^(Loaded|updateinfo)/); next if (/^(Loaded|updateinfo)/);
if (/^\S+\s+(\S*[Ss]ec\S*)\s+(\S+?)\-([0-9]\S+)\.([^\.]+)$/) { if (/^\S+\s+\S+\s+(\S+?)\-([0-9]\S+)\.([^\.]+)$/) {
local ($name, $ver) = ($2, $3); local ($name, $ver) = ($1, $2);
if ($done->{$name}) { if ($done->{$name}) {
$done->{$name}->{'source'} ||= 'security'; $done->{$name}->{'source'} ||= 'security';
$done->{$name}->{'security'} = 1; $done->{$name}->{'security'} = 1;

View File

@@ -1,118 +0,0 @@
#!/usr/bin/perl
# Exercise CGI escaping without issuing certificates or signaling processes.
use strict;
use warnings;
no warnings qw(once redefine uninitialized);
use Test::More;
use FindBin;
my $root = "$FindBin::Bin/..";
require "$root/web-lib-funcs.pl";
require "$root/ui-lib.pl";
$INC{'./acl-lib.pl'} = $INC{'./proc-lib.pl'} = 1;
*main::ReadParse = *main::switch_acl_uid = *main::webmin_log = sub { };
*main::error_setup = *main::get_miniserv_config = sub { };
*main::error = sub { die $_[0]."\n" };
*main::get_module_variable = sub { return \%main::text };
*main::ui_print_unbuffered_header = sub { };
*main::load_theme_library = sub { };
*main::get_module_name = sub { 'proc' };
*main::get_module_info = sub { return () };
*main::get_webprefix = sub { '' };
for my $file ('lang/en', 'acl/lang/en', 'proc/lang/en') {
open(my $fh, '<', "$root/$file") or die $!;
while (<$fh>) {
$main::text{$1} = $2 if /^(\S+)=(.*)$/;
}
}
# render_cgi(file, inputs) captures the real CGI and UI helpers.
sub render_cgi
{
my ($file, $inputs) = @_;
local %main::in = %$inputs;
my $html = '';
open(my $out, '>', \$html) or die $!;
local *STDOUT = $out;
do "$root/$file";
return ($html, $@);
}
subtest 'certificate success and error output' => sub {
# Stub only certificate I/O and account writes; preserve submitted values.
my ($saved, $openssl_error);
local *main::transname = local *main::tempname = sub { '/fixture/cert' };
local *main::open_tempfile = local *main::print_tempfile =
local *main::close_tempfile = local *main::unlink_file = sub { };
local *main::get_ssleay = sub { 'openssl' };
local *main::backquote_logged = sub {
$? = $openssl_error ? 256 : 0;
return $openssl_error || '';
};
local *main::list_users = sub { return {name => 'fixture'} };
# Stop after the tested account write, before the CGI's delay and restart.
local *main::modify_user = sub { $saved = $_[1]->{cert}; die "fixture done\n" };
local $main::base_remote_user = 'fixture';
my $payload = '<script>alert(1)</script>';
for my $name ('Review User', q{O'Reilly & Partners}, $payload) {
my ($html, $error) = render_cgi('acl/cert_issue.cgi',
{ key => 'fixture-key', commonName => $name });
is($error, "fixture done\n", 'successful issuance reaches account update');
like($html, qr/\Q@{[main::html_escape($name)]}\E/,
'common name appears as text');
unlike($html, qr/\Q$payload\E/, 'common name cannot introduce a script');
like($saved, qr/\Q\/CN=$name\/Email=\E/, 'stored DN retains the original name');
}
$openssl_error = "Invalid subject $payload\n";
my ($html, $error) = render_cgi('acl/cert_issue.cgi',
{ key => 'fixture-key', commonName => 'Review User' });
like($error, qr/<pre>Invalid subject &lt;script&gt;/,
'OpenSSL diagnostics retain preformatted markup and escape input');
unlike($error, qr/\Q$payload\E/, 'OpenSSL errors cannot introduce a script');
};
subtest 'single-process permission error heading' => sub {
my ($heading, $pid);
local *main::error_setup = sub { $heading = $_[0] };
local *main::process_info = sub { $pid = $_[0]; return (user => 'fixture') };
local *main::can_edit_process = sub { 0 };
local *main::kill_logged = sub { die "unexpected signal\n" };
my $payload = '<script>alert(1)</script>';
for my $inputs ({signal => 'TERM', pid => '12345'},
{signal => $payload, pid => $payload}) {
my ($html, $error) = render_cgi('proc/kill_proc.cgi', $inputs);
is($error, "$main::text{'kill_ecannot'}\n", 'signaling is denied');
is($heading, main::text('kill_err',
main::html_escape($inputs->{signal}),
main::html_escape($inputs->{pid})),
'error heading renders submitted values as text');
is($pid, $inputs->{pid}, 'process lookup receives the original PID');
}
};
subtest 'process output and return link' => sub {
my @signals;
local *main::list_processes = sub { return map { +{user => 'fixture'} } @_ };
local *main::can_edit_process = sub { 1 };
local *main::kill_logged = sub { push @signals, [@_]; return 1 };
my $query = 'search=worker%20name&match=1';
my ($html, $error) = render_cgi('proc/kill_proc_list.cgi',
{pidlist => '12345 12346', signal => 'TERM', args => $query});
is($error, '', 'ordinary request renders successfully');
is_deeply(\@signals, [['TERM', '12345'], ['TERM', '12346']],
'signal and PID arguments are unchanged');
$html =~ /href="(index_search\.cgi\?[^"]+)"/;
is(main::html_unescape($1), "index_search.cgi?$query",
'HTML escaping preserves the return query');
my $payload = '<script>alert(1)</script>';
($html, $error) = render_cgi('proc/kill_proc_list.cgi',
{pidlist => $payload, signal => $payload, args => '">'.$payload});
is($error, '', 'hostile values render successfully');
unlike($html, qr/\Q$payload\E/, 'PID, signal and return link cannot inject scripts');
like($html, qr/SIG&lt;script&gt;/, 'signal output is escaped');
like($html, qr/href="index_search.cgi\?&quot;&gt;&lt;script&gt;/,
'return link cannot escape its quoted attribute');
};
done_testing();

123
t/net-netplan-vm.t Normal file
View File

@@ -0,0 +1,123 @@
#!/usr/bin/perl
# Run with WEBMIN_NETPLAN_VM_TEST=1 on a Linux debug VM with Netplan installed.
use strict;
use warnings;
no warnings 'once';
use Test::More;
use File::Path qw(make_path);
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../net";
plan skip_all => 'Set WEBMIN_NETPLAN_VM_TEST=1 on a Linux debug VM'
unless ($ENV{'WEBMIN_NETPLAN_VM_TEST'} || '') eq '1';
$^O eq 'linux' or die 'Linux VM required';
require "$FindBin::Bin/../web-lib-funcs.pl";
require "$FindBin::Bin/../net/netplan-lib.pl";
local %main::gconfig = ();
local $main::readonly_mode_cache = 0;
# Load real address helpers without initializing the VM's Webmin configuration.
my $net_lib = main::read_file_contents("$FindBin::Bin/../net/net-lib.pl");
foreach my $name (qw(prefix_to_mask mask_to_prefix log2)) {
my ($code) = $net_lib =~ /^(sub \Q$name\E\n\{.*?^\})/ms;
$code or die "Cannot find $name";
eval "no strict; $code";
die $@ if $@;
}
# Keep realistic addresses, DNS, routes, and comments in the surviving port.
my $header = "network:\n version: 2\n";
my $global = $header." renderer: networkd\n";
my $ethernet = <<'YAML';
ethernets:
eth0:
# Preserve the port's static settings.
addresses: [192.0.2.10/24, '2001:db8::10/64']
nameservers:
addresses: [192.0.2.53]
search: [example.test]
routes:
- to: default
via: 192.0.2.1
eth1:
dhcp4: true
YAML
my $bridge0 = " br0:\n interfaces: [eth0]\n";
my $bridge1 = " br1:\n interfaces: [eth1]\n";
my $standalone = " br0:\n interfaces: []\n dhcp4: true\n";
my $other_groups = <<'YAML';
vlans:
vlan10:
id: 10
link: eth0
bonds:
bond0:
interfaces: [eth1]
parameters:
mode: active-backup
YAML
my $inline = " ethernets: {eth0: {dhcp4: true}}\n";
my $bulk = $global." ethernets: {}\n bridges:\n".
" br0:\n dhcp4: false\n br1:\n dhcp4: true\n";
# Each case supplies valid input, deletion order, and exact expected contents.
foreach my $case (
['shared bridge', $header.$ethernet." bridges:\n".$bridge0,
['br0'], $header.$ethernet],
['remaining bridge', $header.$ethernet." bridges:\n".$bridge0.$bridge1,
['br0'], $header.$ethernet." bridges:\n".$bridge1],
['last Ethernet', $header." ethernets:\n eth0: {}\n",
['eth0'], undef],
['last bridge', $header." bridges:\n".$standalone,
['br0'], undef],
['VLAN and bond settings', $header.$ethernet.$other_groups.
" bridges:\n".$standalone, ['br0'], $header.$ethernet.$other_groups],
['inline Ethernet mapping', $header.$inline." bridges:\n".$bridge0,
['br0'], $header.$inline],
['bulk deletion preserves existing empty groups', $bulk, ['br1', 'br0'],
$global." ethernets: {}\n"],
['bulk deletion follows display order',
$global." ethernets:\n eth0: {}\n bridges:\n".$standalone,
['eth0', 'br0'], $global],
['virtual IPv4 address', $header." ethernets:\n eth0:\n".
" addresses: [192.0.2.10/24, 192.0.2.11/24]\n",
['eth0:0'], $header." ethernets:\n eth0:\n".
" addresses: ['192.0.2.10/24']\n"],
) {
subtest $case->[0] => sub {
# Generate only beneath this temporary root, without applying networking.
my $root = tempdir(CLEANUP => 1);
local $main::netplan_dir = "$root/etc/netplan";
make_path($main::netplan_dir);
my $file = "$main::netplan_dir/shared.yaml";
my $control = "$main::netplan_dir/00-renderer.yaml";
main::write_file_contents($file, $case->[1]);
main::write_file_contents($control, $global);
chmod(0600, $file, $control) == 2 or die "chmod: $!";
is(system('netplan', 'generate', '--root-dir', $root), 0,
'original configuration passes Netplan');
# Match the UI's reuse of one interface list throughout bulk deletion.
my @boot = main::boot_interfaces();
foreach my $name (@{$case->[2]}) {
my ($iface) = grep { $_->{'fullname'} eq $name } @boot;
$iface or die "Fixture interface $name not found";
main::delete_interface($iface);
}
if (defined($case->[3])) {
# Surviving settings and comments must retain their exact contents.
is(main::read_file_contents($file), $case->[3],
'remaining configuration is unchanged');
}
else {
# No interface or global setting remains in this file.
ok(!-e $file, 'empty file is removed');
}
is(main::read_file_contents($control), $global,
'another YAML file is untouched');
is(system('netplan', 'generate', '--root-dir', $root), 0,
'resulting configuration passes Netplan');
};
}
done_testing();

65
t/net-netplan.t Normal file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/perl
# Exercise Netplan deletion using temporary files only, without host configuration.
use strict;
use warnings;
no warnings 'once';
use Test::More;
use File::Temp qw(tempdir);
use FindBin;
use lib "$FindBin::Bin/../net";
require "$FindBin::Bin/../web-lib-funcs.pl";
require "$FindBin::Bin/../net/netplan-lib.pl";
local %main::gconfig = ();
local $main::readonly_mode_cache = 0;
my $root = tempdir(CLEANUP => 1);
my $file = "$root/shared.yaml";
local $main::netplan_dir = $root;
# Empty detection must retain real interface entries and global settings.
foreach my $case (
["network:\n version: 2\n ethernets:\n bridges:\n", 1, 'empty interface groups'],
["network:\n version: 2\n ethernets:\n eth0: {}\n", 0, 'empty Ethernet definition'],
["network:\n version: 2\n bridges:\n br1:\n dhcp4: true\n", 0, 'another bridge'],
["network:\n version: 2\n renderer: networkd\n", 0, 'global renderer'],
["network:\n version: 2\n vlans:\n vlan10:\n id: 10\n link: eth0\n", 0, 'VLAN settings']) {
main::write_file_contents($file, $case->[0]);
main::unflush_file_lines($file);
is(main::is_yaml_empty($file), $case->[1], "$case->[2] handled correctly");
}
# Deleting the last bridge must retain its port and unrelated YAML byte for byte.
my $remaining = "network:\n version: 2\n ethernets:\n eth0:\n dhcp4: true\n".
" eth1:\n dhcp4: false\n";
my $original = $remaining." bridges:\n br0:\n interfaces: [eth0]\n";
main::write_file_contents($file, $original);
main::unflush_file_lines($file);
main::delete_interface((grep { $_->{'fullname'} eq 'br0' } main::boot_interfaces())[0]);
ok(-f $file, 'shared YAML survives bridge deletion');
is(main::read_file_contents($file), $remaining, 'empty group removed, Ethernet settings untouched');
# Removing one bridge must preserve the group and the remaining bridge.
my $second = " br1:\n interfaces: [eth1]\n";
main::write_file_contents($file, $original.$second);
main::unflush_file_lines($file);
main::delete_interface((grep { $_->{'fullname'} eq 'br0' } main::boot_interfaces())[0]);
is(main::read_file_contents($file), $remaining." bridges:\n".$second,
'another bridge and its group remain unchanged');
# A bridge-only file is removed once its last interface is gone.
main::write_file_contents($file, "network:\n version: 2\n bridges:\n br0:\n dhcp4: true\n");
main::unflush_file_lines($file);
main::delete_interface((grep { $_->{'fullname'} eq 'br0' } main::boot_interfaces())[0]);
ok(!-e $file, 'empty bridge-only file is removed');
# Bulk deletion reuses interface details after earlier deletions shift lines.
my $global = "network:\n version: 2\n renderer: networkd\n";
main::write_file_contents($file, $global." ethernets: {}\n bridges:\n".
" br0:\n dhcp4: false\n br1:\n dhcp4: true\n");
main::unflush_file_lines($file);
my @boot = main::boot_interfaces();
foreach my $iface (@boot) {
main::delete_interface($iface);
}
is(main::read_file_contents($file), $global." ethernets: {}\n",
'bulk deletion handles line shifts and preserves unrelated settings');
done_testing();

View File

@@ -1 +1 @@
2.671 2.670