diff --git a/CHANGELOG.md b/CHANGELOG.md index f0229ec35..ea3bd23be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,7 @@ ## Changelog #### 2.654 (August, 2026) * Add incremental ban time options to the Fail2Ban module +* Add Btrfs subvolume quota management to the Disk Quotas module, with full and simple accounting modes * Fix to ignore failures when adding IPv6 link-local (fe80::) addresses that may already be configured automatically * Update the Authentic theme to the latest version with various improvements: - Fix disallowed entry handling in File Manager [forum.virtualmin.com/t/137654](https://forum.virtualmin.com/t/extra-admin-file-manager-permissions/137654?u=ilia) diff --git a/quota/btrfs_action.cgi b/quota/btrfs_action.cgi new file mode 100755 index 000000000..099e8431c --- /dev/null +++ b/quota/btrfs_action.cgi @@ -0,0 +1,61 @@ +#!/usr/local/bin/perl +# Enable, disable or rescan Btrfs quotas + +require './quota-lib.pl'; +&ReadParse(); +$dir = $in{'dir'}; + +# Require quota activation access and a valid mounted Btrfs filesystem before +# running any command that can change filesystem quota state. +&can_edit_btrfs_filesys($dir) && $access{'enable'} && !$access{'ro'} || + &error($text{'btrfs_eenable'}); +defined(&btrfs_quota_status) && &is_btrfs_fs($dir) || + &error($text{'btrfs_enotbtrfs'}); + +# Accept only the three operations implemented by this handler. +$in{'action'} =~ /^(enable|disable|rescan)$/ || + &error($text{'btrfs_eaction'}); + +# Disabling Btrfs quotas removes every qgroup and limit, so require an explicit +# confirmation before performing this destructive operation. +if ($in{'action'} eq "disable" && !$in{'confirm'}) { + # Mark the filesystem and qgroup terms as literal technical values. + my $dir_label = &ui_tag("tt", &html_escape($dir)); + my $qgroup_label = &ui_tag("tt", "qgroup"); + chomp($dir_label); + chomp($qgroup_label); + + # Display the destructive warning inside the confirmation form. + &ui_print_header(undef, $text{'btrfs_disable'}, "", "btrfs"); + print &ui_confirmation_form( + "btrfs_action.cgi", + &text('btrfs_disable_confirm', $dir_label), + [ [ "dir", $dir ], [ "action", "disable" ] ], + [ [ "confirm", $text{'btrfs_disable'} ] ], + &ui_alert_box(&text('btrfs_disable_warning', $qgroup_label), "warn", + undef, undef, "")); + &ui_print_footer("", $text{'index_return'}); + exit; + } + +&error_setup($text{'btrfs_efailed'}); +# Enable quotas using the accounting mode selected in the module configuration. +if ($in{'action'} eq "enable") { + $err = &enable_btrfs_quotas($dir, + $config{'btrfs_mode'} eq "simple" ? 1 : 0); + } +# Disable quotas after the confirmation branch above has been completed. +elsif ($in{'action'} eq "disable") { + $err = &disable_btrfs_quotas($dir); + } +# The remaining valid action starts a full-accounting quota rescan. +else { + $err = &rescan_btrfs_quotas($dir, 0); + } + +# Report command failures, record successful changes, and return to the most +# relevant page for the completed action. +&error($err) if ($err); +&webmin_log($in{'action'}, "btrfs", $dir, \%in); +&redirect($in{'action'} eq "rescan" ? + "list_btrfs.cgi?dir=".&urlize($dir) : ""); diff --git a/quota/config-ALL-linux b/quota/config-ALL-linux index 370c3c8c2..c6658128a 100644 --- a/quota/config-ALL-linux +++ b/quota/config-ALL-linux @@ -23,3 +23,4 @@ show_grace=1 email_msg=Disk usage for user ${USER} on filesystem ${FS} has reached ${PERCENT}% of the allowed quota. ${USED} of disk space is being used, out of a maximum of ${QUOTA}. pc_show=1 hide_uids=0 +btrfs_mode=full diff --git a/quota/config.info b/quota/config.info index 0f5dfcae4..28e1456d8 100644 --- a/quota/config.info +++ b/quota/config.info @@ -5,6 +5,7 @@ pc_show=Usage percentages to show,1,2-Hard and soft,1-Hard only,0-Soft only sort_mode=Sort users and groups by,1,0-Blocks used,2-Name,1-Order from repquota,3-Hard block quota,4-Soft block quota,5-Percent of hard quota used,6-Percent of soft quota used block_mode=Show quotas in,1,1-Kilobytes (where possible),0-Blocks hide_uids=Show deleted users?,1,0-Yes,1-No +btrfs_mode=Btrfs accounting mode when enabling quotas,15,btrfs_mode line1.1=Quota email messages,11 email_msg=Email message for users over quota,9,80,5,\t email_subject=Subject for email message to users,3,Default diff --git a/quota/config_info.pl b/quota/config_info.pl new file mode 100755 index 000000000..e879f6d35 --- /dev/null +++ b/quota/config_info.pl @@ -0,0 +1,36 @@ +# Build and parse the Btrfs-specific module configuration field. +require './quota-lib.pl'; + +# config_pre_load(info, [order]) +# Hide Btrfs-specific settings unless both a mounted Btrfs filesystem and the +# command-line tool needed to manage it are available. +sub config_pre_load +{ +my ($info, $order) = @_; +my @btrfs = &list_btrfs_filesystems(); +return if (@btrfs && &has_command("btrfs")); + +# Remove the field from both the configuration metadata and display order. +delete($info->{'btrfs_mode'}); +@$order = grep { $_ ne "btrfs_mode" } @$order if ($order); +} + +# show_btrfs_mode(mode) +# Display the accounting mode selector, defaulting unknown values to full mode. +sub show_btrfs_mode +{ +my ($mode) = @_; +$mode = "full" if ($mode ne "simple"); +return &ui_radio("btrfs_mode", $mode, + [ [ "full", $text{'config_btrfs_full'} ], + [ "simple", $text{'config_btrfs_simple'} ] ]); +} + +# parse_btrfs_mode() +# Store only a supported mode and fall back to full accounting otherwise. +sub parse_btrfs_mode +{ +return $in{'btrfs_mode'} eq "simple" ? "simple" : "full"; +} + +1; diff --git a/quota/edit_btrfs.cgi b/quota/edit_btrfs.cgi new file mode 100755 index 000000000..972868c76 --- /dev/null +++ b/quota/edit_btrfs.cgi @@ -0,0 +1,54 @@ +#!/usr/local/bin/perl +# Edit the limits for a Btrfs qgroup + +require './quota-lib.pl'; +&ReadParse(); +$dir = $in{'dir'}; + +# Limit editing requires write access to an allowed mounted Btrfs filesystem +# and a syntactically valid qgroup ID. +$access{'ro'} && &error($text{'btrfs_eedit'}); +&can_edit_btrfs_filesys($dir) || &error($text{'btrfs_eallow'}); +defined(&btrfs_quota_status) && &is_btrfs_fs($dir) || + &error($text{'btrfs_enotbtrfs'}); +&valid_btrfs_qgroup_id($in{'qgroup'}) || &error($text{'btrfs_eqgroup'}); +$in{'qgroup'} eq "0/5" && &error($text{'btrfs_etoplevel'}); + +# Load the current qgroups and ensure the requested ID still exists. +$qgroups = &list_btrfs_qgroups($dir, 0, \$listerr); +&error($listerr) if (!$qgroups); +($qgroup) = grep { $_->{'id'} eq $in{'qgroup'} } @$qgroups; +$qgroup || &error($text{'btrfs_eqgroup'}); + +# Start a form bound to the selected filesystem and qgroup. +&ui_print_header(undef, $text{'btrfs_edit_title'}, "", "btrfs"); +print "

$text{'btrfs_edit_info'}

\n"; +print &ui_form_start("save_btrfs.cgi", "post"); +print &ui_hidden("dir", $dir); +print &ui_hidden("qgroup", $qgroup->{'id'}); +print &ui_table_start(&text('btrfs_edit_header', + &html_escape($qgroup->{'id'}), &html_escape($dir)), "width=100%", 2); + +# Show the current path and accounted usage as read-only values. +print &ui_table_row($text{'btrfs_path'}, + $qgroup->{'path'} ne "" ? &html_escape($qgroup->{'path'}) : "-"); +print &ui_table_row($text{'btrfs_referenced'}, + &nice_size($qgroup->{'referenced'})); +print &ui_table_row($text{'btrfs_exclusive'}, + &nice_size($qgroup->{'exclusive'})); +print &ui_table_hr(); + +# Allow referenced and exclusive limits to be changed independently. +print &ui_table_row($text{'btrfs_max_referenced'}, + "a_input("max_referenced", + defined($qgroup->{'max_referenced'}) ? + $qgroup->{'max_referenced'} : 0, 1)); +print &ui_table_row($text{'btrfs_max_exclusive'}, + "a_input("max_exclusive", + defined($qgroup->{'max_exclusive'}) ? + $qgroup->{'max_exclusive'} : 0, 1)); +print &ui_table_end(); +print &ui_form_end([ [ undef, $text{'btrfs_update'} ] ]); + +# Return to the qgroup list for this filesystem. +&ui_print_footer("list_btrfs.cgi?dir=".&urlize($dir), $text{'btrfs_title'}); diff --git a/quota/help/btrfs.html b/quota/help/btrfs.html new file mode 100644 index 000000000..0e80eb9c9 --- /dev/null +++ b/quota/help/btrfs.html @@ -0,0 +1,53 @@ +
Btrfs Subvolume Quotas
+ +

Introduction

+Btrfs quotas control disk usage for subvolumes through quota groups, usually +called qgroups. Unlike traditional Unix quotas, they do not limit an +individual user or group and do not provide soft limits, grace periods, or +file-count limits.

+ +Each Btrfs subvolume has a level-0 qgroup. The module displays the +following usage and limit values for each qgroup : +

+
Referenced +
All data reachable from the subvolume, including data shared with other +subvolumes or snapshots. +
Exclusive +
Data used only by the subvolume, which would be freed if it were deleted. +
Referenced limit +
The maximum referenced space that the qgroup may use. +
Exclusive limit +
The maximum exclusive space that the qgroup may use. +
+ +

Accounting Modes

+When Btrfs quotas are enabled, the accounting mode configured in the module +settings is used : +
+
Full accounting +
Tracks shared space between subvolumes and snapshots. This is the +recommended mode when accurate referenced and exclusive usage is required. +
Simple accounting +
Tracks original ownership with lower overhead, but does not fully track +space shared between subvolumes and snapshots. +
+Changing the module setting does not convert an already-enabled filesystem. +The selected mode is used the next time quotas are enabled.

+ +

Managing Btrfs Quotas

+The main module page shows each mounted Btrfs filesystem, its accounting mode, +consistency state, and an action to enable or disable quotas. Click a filesystem +path to view its qgroups, usage, and limits. Click a qgroup ID +to edit its referenced and exclusive limits.

+ +Because quota state and qgroup IDs apply to the whole underlying Btrfs +filesystem, delegated Webmin users must be allowed to manage all filesystems to +access these controls. A permission scoped to one mounted subvolume is not +sufficient.

+ +Full accounting also provides a rescan action for rebuilding qgroup +accounting in the background. Disabling Btrfs quotas removes all +qgroup configuration and limits on the filesystem, so the module +always requests confirmation first.

+ +


diff --git a/quota/index.cgi b/quota/index.cgi index 69cdb8139..b599dafe3 100755 --- a/quota/index.cgi +++ b/quota/index.cgi @@ -1,21 +1,31 @@ #!/usr/local/bin/perl # index.cgi # Display a list of all local filesystems, and allow editing of quotas -# on those which have quotas turned on. The actual turning on of quotas must -# be done in the mount module first. +# on those which have quotas turned on. Traditional quota mount options are +# configured in the mount module, while Btrfs quotas are managed here. require './quota-lib.pl'; -&ui_print_header(undef, $text{'index_title'}, "", "intro", 1, 1, 0, + +# Discover allowed Btrfs mounts independently of the traditional quota tools. +@btrfs = grep { &can_edit_btrfs_filesys($_->[0]) } &list_btrfs_filesystems(); +$err = "as_init(); + +# Traditional filesystems are unavailable when quota-tools initialization fails. +@list = $err ? ( ) : &list_filesystems(); + +# Use focused Btrfs help when it is the only quota model shown on this page. +$help = @btrfs && !@list ? "btrfs" : "intro"; +&ui_print_header(undef, $text{'index_title'}, "", $help, 1, 1, 0, &help_search_link("quota", "man", "howto")); -$err = "as_init(); -if ($err) { +# Stop only when neither traditional quota tools nor Btrfs tools can provide a +# usable filesystem list. +if ($err && (!@btrfs || !&has_command("btrfs"))) { print "

$err

\n"; &ui_print_footer("/", $text{'index_return'}); exit; } -@list = &list_filesystems(); if (@list) { print &ui_columns_start([ $text{'index_fs'}, @@ -97,13 +107,84 @@ if (@list) { } print &ui_columns_end(); } -else { +# Report no support only when neither traditional nor Btrfs filesystems exist. +elsif (!@btrfs) { print "$text{'index_nosupport'}

\n"; if (&foreign_available("mount")) { print &text('index_mountmod', "../mount/"),"

\n"; } } +# Btrfs subvolume quotas use qgroups instead of Unix users and groups, so they +# are shown separately from the traditional quota filesystems above. +if (@btrfs) { + # Activation controls require both enable permission and write access. + $btrfs_canactivate = $access{'enable'} && !$access{'ro'}; + + # Start a table with an action column only for users who can change state. + print &ui_columns_start([ + $text{'index_fs'}, + $text{'index_type'}, + $text{'index_mount'}, + $text{'index_status'}, + $btrfs_canactivate ? ( $text{'index_action'} ) : (), + ], 100, 0, undef, &hlink($text{'index_btrfs_title'}, "btrfs")); + foreach $f (@btrfs) { + # Query each mount independently so failures remain visible per row. + undef($action); + $status = &btrfs_quota_status($f->[0]); + + # The OS library could not identify this path as manageable Btrfs. + if (!$status) { + $msg = $text{'index_btrfs_unavailable'}; + } + # Surface command or parsing errors without offering a state change. + elsif ($status->{'error'}) { + $msg = &text('index_btrfs_error', + &html_escape($status->{'error'})); + } + # Disabled filesystems can be enabled using the configured mode. + elsif (!$status->{'enabled'}) { + $msg = $text{'index_btrfs_disabled'}; + $action = "enable"; + } + # Enabled filesystems expose their accounting and consistency state. + else { + $mode = $status->{'mode'} eq "squota" ? + $text{'index_btrfs_simple'} : + $status->{'mode'} eq "qgroup" ? + $text{'index_btrfs_full'} : + $text{'index_btrfs_unknown'}; + $msg = &text('index_btrfs_enabled', $mode); + $msg .= ", $text{'index_btrfs_inconsistent'}" + if ($status->{'inconsistent'}); + $action = "disable"; + } + + # Build the common filesystem, type, source and status columns. + local @cols = ( + &ui_link("list_btrfs.cgi?dir=".&urlize($f->[0]), + &html_escape($f->[0])), + &foreign_call("mount", "fstype_name", $f->[2]), + &foreign_call("mount", "device_name", $f->[1]), + $msg, + ); + + # Add the state-changing link only when the ACL allows it. + if ($btrfs_canactivate) { + push(@cols, $action ? + &ui_link("btrfs_action.cgi?dir=".&urlize($f->[0]). + "&action=$action", + $action eq "enable" ? $text{'index_enable'} : + $text{'index_disable'}) : "-"); + } + print &ui_columns_row(\@cols); + } + + # Close the separately titled Btrfs filesystem table. + print &ui_columns_end(); + } + # Buttons to edit and specific user or group if ($useractive || $groupactive) { print &ui_hr(); diff --git a/quota/install_check.pl b/quota/install_check.pl index 9e5911d7d..d031ce62e 100755 --- a/quota/install_check.pl +++ b/quota/install_check.pl @@ -8,9 +8,15 @@ do 'quota-lib.pl'; # For mode 0, returns 1 if installed, 0 if not sub is_installed { +# Check the traditional quota-tools dependency when this OS implements it. if (defined("as_init)) { local $err = "as_init(); - return 0 if ($err); + # A usable Btrfs mount and command provide an alternative when the + # traditional quota-tools package is not installed. + if ($err) { + local @btrfs = &list_btrfs_filesystems(); + return 0 if (!@btrfs || !&has_command("btrfs")); + } } return $_[0] ? 2 : 1; } diff --git a/quota/lang/en b/quota/lang/en index 17aa8497b..ba00c51f2 100644 --- a/quota/lang/en +++ b/quota/lang/en @@ -167,6 +167,59 @@ index_egroup=Edit Group Quotas: index_egroupdesc=Enter or select a group, and click this button to view its quotas on all filesystems. index_nosupport=No local filesystems can support quotas. index_mountmod=You can enable quotas for a filesystem in the Disk and Network Filesystems module. +index_btrfs_title=Btrfs Subvolume Quotas +index_btrfs_enabled=Enabled, $1 +index_btrfs_disabled=Disabled +index_btrfs_full=full accounting +index_btrfs_simple=simple accounting +index_btrfs_unknown=accounting mode unavailable +index_btrfs_inconsistent=inconsistent +index_btrfs_unavailable=Unavailable +index_btrfs_error=Error: $1 + +config_btrfs_full=Full accounting (recommended; tracks shared space and snapshots) +config_btrfs_simple=Simple accounting (lower overhead; tracks original ownership only) + +btrfs_title=Btrfs Subvolume Quotas +btrfs_return=filesystem list +btrfs_status_header=Quota status for $1 +btrfs_status=Status +btrfs_enabled=Enabled +btrfs_disabled=Disabled +btrfs_mode=Accounting mode +btrfs_full=Full accounting +btrfs_simple=Simple accounting +btrfs_unknown=Unavailable from this version of btrfs-progs +btrfs_consistency=Accounting state +btrfs_consistent=Consistent +btrfs_inconsistent=Inconsistent - a rescan is recommended +btrfs_qgroups=Subvolume quota groups +btrfs_qgroup=qgroup +btrfs_path=Subvolume path +btrfs_referenced=Referenced usage +btrfs_exclusive=Exclusive usage +btrfs_max_referenced=Referenced limit +btrfs_max_exclusive=Exclusive limit +btrfs_disable=Disable quotas +btrfs_disable_confirm=Disable Btrfs quotas on $1? +btrfs_disable_warning=All $1 configuration and limits on this filesystem will be removed! +btrfs_rescan=Rescan quotas +btrfs_rescan_desc=Rebuild full qgroup accounting in the background. +btrfs_edit_title=Edit Btrfs Quota +btrfs_edit_header=Limits for qgroup $1 on $2 +btrfs_edit_info=Referenced usage includes shared data reachable from the subvolume. Exclusive usage is the space that would be freed with it. +btrfs_update=Update +btrfs_eallow=Btrfs quota management requires permission to manage all filesystems because qgroups apply to the whole underlying filesystem +btrfs_eenable=You are not allowed to enable or disable quotas on this filesystem +btrfs_eedit=You are not allowed to edit Btrfs quota limits +btrfs_enotbtrfs=The selected path is not on a mounted Btrfs filesystem +btrfs_eqgroup=The selected Btrfs qgroup does not exist +btrfs_etoplevel=The top-level Btrfs qgroup cannot be limited because doing so can block the entire filesystem +btrfs_eaction=Invalid Btrfs quota action +btrfs_efailed=Failed to manage Btrfs quotas +btrfs_esave=Failed to save the Btrfs quota +btrfs_elimit=Quota limits must be positive numbers +btrfs_emax=You are not allowed to grant limits above $1 lgroups_failed=Failed to list groups lgroups_tablist=Group list diff --git a/quota/linux-lib.pl b/quota/linux-lib.pl index c9a3a3bc5..188c0fb05 100755 --- a/quota/linux-lib.pl +++ b/quota/linux-lib.pl @@ -82,6 +82,12 @@ the following : =cut sub quota_can { +my ($mnttab) = @_; + +# The quota-tools commands used by this module cannot reliably manage tmpfs +# mounts, even when they expose usrquota or grpquota mount options. +return 0 if ($mnttab->[2] eq "tmpfs"); + my %exclude_mounts; if (&has_command("findmnt")) { %exclude_mounts = map { $_ => 1 } split( /\n/m, backquote_command('findmnt -r | grep -oP \'^(\S+)(?=.*\[\/)\'') ); @@ -1168,6 +1174,53 @@ $rv{'levels'} = \%levels if (%levels); return \%rv; } +# btrfs_filesystem_uuid(path) +# Returns the UUID of the Btrfs filesystem containing a path. +sub btrfs_filesystem_uuid +{ +my ($path) = @_; +my ($out, $err) = &run_btrfs_command( + 0, "filesystem", "show", "--raw", $path); +return undef if (!defined($out) || + $out !~ /^\s*Label:.*\buuid:\s*([0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12})\s*$/mi); +return lc($1); +} + +# btrfs_sysfs_quota_status(path) +# Reads quota state exported by the kernel. This preserves accounting mode and +# consistency information on btrfs-progs releases older than `quota status`. +sub btrfs_sysfs_quota_status +{ +my ($path) = @_; +my $uuid = &btrfs_filesystem_uuid($path); +return undef if (!$uuid); +my $sysfs = $btrfs_sysfs_root || "/sys/fs/btrfs"; +my $qdir = "$sysfs/$uuid/qgroups"; +return undef if (!-d $qdir); + +my %rv = ( 'supported' => 1, 'enabled' => 1 ); +foreach my $field (qw(enabled mode inconsistent)) { + my $file = "$qdir/$field"; + next if (!-r $file); + open(my $fh, "<", $file) || next; + my $value = <$fh>; + close($fh); + next if (!defined($value)); + $value =~ s/^\s+|\s+$//g; + if ($field eq "mode" && $value =~ /^(qgroup|squota)$/) { + $rv{$field} = $value; + } + elsif ($field ne "mode" && $value =~ /^([01])$/) { + $rv{$field} = int($1); + } + } + +# Kernels predating simple quotas expose the qgroups directory without a mode +# file. Their only possible accounting mode is full qgroups. +$rv{'mode'} = "qgroup" if (!defined($rv{'mode'}) && !-e "$qdir/mode"); +return \%rv; +} + =head2 btrfs_quota_status(path) Returns a hash reference describing the Btrfs quota status for a path. The @@ -1194,8 +1247,20 @@ if (defined($out)) { # quotas are enabled, and reports a missing quota root when disabled. my ($qout, $qerr) = &run_btrfs_command(0, "qgroup", "show", "--raw", $path); if (defined($qout)) { - return { 'supported' => 1, - 'enabled' => 1 }; + my $rv = &btrfs_sysfs_quota_status($path) || + { 'supported' => 1, 'enabled' => 1 }; + # Old qgroup-show versions warn on stdout when the counters are + # inconsistent. Retain that signal if sysfs did not provide the flag. + if (!defined($rv->{'inconsistent'})) { + $rv->{'inconsistent'} = + $qout =~ /^\s*(?:warning|error):.*qgroup.*inconsistent/mi ? + 1 : 0; + } + # A simple-quota space holder is definitive even when sysfs is unavailable. + $rv->{'mode'} = "squota" + if (!defined($rv->{'mode'}) && + $qout =~ //i); + return $rv; } elsif ($qerr =~ /(?:quota root does not exist|quotas? (?:are |is )?not enabled)/i) { return { 'supported' => 1, @@ -1243,6 +1308,27 @@ foreach my $line (split(/\r?\n/, $out)) { return \@rv; } +=head2 parse_btrfs_subvolume_list_output(output) + +Parses raw output from C and returns a hash reference +mapping numeric subvolume IDs to filesystem-relative paths. This is used to +fill qgroup paths on btrfs-progs versions older than 6.0.1. + +=cut +sub parse_btrfs_subvolume_list_output +{ +my ($out) = @_; +my %rv; +foreach my $line (split(/\r?\n/, $out)) { + # The default output ends in "path ". + # Keep the final field intact because Btrfs paths may contain spaces. + if ($line =~ /^ID\s+(\d+)\s+.*?\s+path\s+(.*)$/) { + $rv{int($1)} = $2; + } + } +return \%rv; +} + =head2 list_btrfs_qgroups(path, [sync], [&error]) Returns an array reference containing all Btrfs qgroups on the filesystem @@ -1272,6 +1358,22 @@ if (!@$rv && $out =~ /\S/) { $$errref = "Unable to parse Btrfs qgroup output" if ($errref); return undef; } +# qgroup paths were not printed by default until btrfs-progs 6.0.1. Populate +# missing level-0 paths from the long-established subvolume-list output so +# callers can keep identifying subvolumes by path on supported older systems. +if (grep { $_->{'id'} =~ /^0\/(\d+)$/ && $_->{'path'} eq '' } @$rv) { + my ($subvolout) = &run_btrfs_command( + 0, "subvolume", "list", $path); + if (defined($subvolout)) { + my $paths = &parse_btrfs_subvolume_list_output($subvolout); + foreach my $q (@$rv) { + if ($q->{'id'} =~ /^0\/(\d+)$/ && $q->{'path'} eq '' && + defined($paths->{$1})) { + $q->{'path'} = $paths->{$1}; + } + } + } + } $$errref = undef if ($errref); return $rv; } @@ -1380,8 +1482,9 @@ return defined($_[0]) && $_[0] =~ /^\d+\/\d+$/ ? 1 : 0; Sets the referenced or exclusive byte limit for a Btrfs qgroup. If qgroup is undef, path must be a subvolume and its level-0 qgroup is changed. If bytes -is undef, the limit is removed. Returns undef on success or an error message -on failure. +is undef, the limit is removed. If an exceeded limit blocks the update, quota +enforcement is temporarily overridden for one retry. Returns undef on success +or an error message on failure. =cut sub set_btrfs_qgroup_limit @@ -1398,6 +1501,43 @@ push(@args, defined($bytes) ? $bytes : "none"); push(@args, $qgroup) if (defined($qgroup)); push(@args, $path); my ($out, $err) = &run_btrfs_command(1, @args); + +# An exceeded qgroup can block the metadata write needed to raise or remove its +# own limit. Retry once with the kernel's administrative override, preserving +# the previous state and restoring enforcement immediately after the command. +if ($err && $err =~ /disk quota exceeded/i) { + my $uuid = &btrfs_filesystem_uuid($path); + my $sysfs = $btrfs_sysfs_root || "/sys/fs/btrfs"; + my $override = $uuid ? "$sysfs/$uuid/quota_override" : undef; + my $current; + if ($override && open(my $fh, "<", $override)) { + $current = <$fh>; + close($fh); + $current =~ s/\s+//g if (defined($current)); + } + + # Only change a known disabled override; an active or unreadable setting + # belongs to the administrator and must not be altered here. + if (defined($current) && $current eq "0") { + my $enabled; + if (open(my $fh, ">", $override)) { + my $written = syswrite($fh, "1\n"); + $enabled = defined($written) && $written == 2; + close($fh); + } + if ($enabled) { + ($out, $err) = &run_btrfs_command(1, @args); + my $restored; + if (open(my $fh, ">", $override)) { + my $written = syswrite($fh, "0\n"); + $restored = defined($written) && $written == 2; + close($fh); + } + return "Failed to restore Btrfs quota enforcement" + if (!$restored); + } + } + } return $err; } diff --git a/quota/list_btrfs.cgi b/quota/list_btrfs.cgi new file mode 100755 index 000000000..06515d7b4 --- /dev/null +++ b/quota/list_btrfs.cgi @@ -0,0 +1,91 @@ +#!/usr/local/bin/perl +# Display Btrfs quota status and subvolume qgroups + +require './quota-lib.pl'; +&ReadParse(); +$dir = $in{'dir'}; + +# Restrict the page to allowed paths on mounted Btrfs filesystems. +&can_edit_btrfs_filesys($dir) || &error($text{'btrfs_eallow'}); +defined(&btrfs_quota_status) && &is_btrfs_fs($dir) || + &error($text{'btrfs_enotbtrfs'}); +&error_setup($text{'btrfs_efailed'}); + +# Read quota status before building the status and qgroup tables. +$status = &btrfs_quota_status($dir); +$status || &error($text{'btrfs_enotbtrfs'}); +&error($status->{'error'}) if ($status->{'error'}); + +&ui_print_header(undef, $text{'btrfs_title'}, "", "btrfs"); + +# Map the command's accounting mode to a user-facing label. +$mode = $status->{'mode'} eq "squota" ? $text{'btrfs_simple'} : + $status->{'mode'} eq "qgroup" ? $text{'btrfs_full'} : + $text{'btrfs_unknown'}; + +# Display the current enablement, accounting mode and consistency state. +print &ui_table_start(&text('btrfs_status_header', &html_escape($dir)), + "width=100%", 2); +print &ui_table_row($text{'btrfs_status'}, + $status->{'enabled'} ? $text{'btrfs_enabled'} : $text{'btrfs_disabled'}); +print &ui_table_row($text{'btrfs_mode'}, $mode) if ($status->{'enabled'}); +# Show consistency only when btrfs-progs or the kernel reports it. +if (defined($status->{'inconsistent'})) { + print &ui_table_row($text{'btrfs_consistency'}, + $status->{'inconsistent'} ? $text{'btrfs_inconsistent'} : + $text{'btrfs_consistent'}); + } +print &ui_table_end(); + +# A disabled filesystem has no qgroups to list or edit. +if (!$status->{'enabled'}) { + &ui_print_footer("", $text{'btrfs_return'}); + exit; + } + +# Load all qgroups and start the usage and limit table. +$qgroups = &list_btrfs_qgroups($dir, 0, \$listerr); +&error($listerr) if (!$qgroups); +print &ui_columns_start([ + $text{'btrfs_qgroup'}, + $text{'btrfs_path'}, + $text{'btrfs_referenced'}, + $text{'btrfs_exclusive'}, + $text{'btrfs_max_referenced'}, + $text{'btrfs_max_exclusive'}, + ], 100, 0, undef, $text{'btrfs_qgroups'}); +foreach $q (@$qgroups) { + # Read-only users and the filesystem-wide top-level qgroup get no edit + # link. Limiting 0/5 can block Webmin from changing the limit back. + $qid = &ui_tag("tt", &html_escape($q->{'id'})); + chomp($qid); + if (!$access{'ro'} && $q->{'id'} ne "0/5") { + $qid = &ui_link("edit_btrfs.cgi?dir=".&urlize($dir). + "&qgroup=".&urlize($q->{'id'}), $qid); + } + + # Display usage and use the standard unlimited label for missing limits. + print &ui_columns_row([ + $qid, + $q->{'path'} ne "" ? &html_escape($q->{'path'}) : "-", + &nice_size($q->{'referenced'}), + &nice_size($q->{'exclusive'}), + defined($q->{'max_referenced'}) ? + &nice_size($q->{'max_referenced'}) : $text{'quota_unlimited'}, + defined($q->{'max_exclusive'}) ? + &nice_size($q->{'max_exclusive'}) : $text{'quota_unlimited'}, + ]); + } +print &ui_columns_end(); + +# Full accounting supports rescanning; simple accounting deliberately hides it. +if (!$access{'ro'} && $access{'enable'} && $status->{'mode'} ne "squota") { + print &ui_hr(); + print &ui_buttons_start(); + print &ui_buttons_row("btrfs_action.cgi", $text{'btrfs_rescan'}, + $text{'btrfs_rescan_desc'}, + [ [ "dir", $dir ], [ "action", "rescan" ] ]); + print &ui_buttons_end(); + } + +&ui_print_footer("", $text{'btrfs_return'}); diff --git a/quota/module.info b/quota/module.info index 13275131e..c87a541bf 100644 --- a/quota/module.info +++ b/quota/module.info @@ -3,5 +3,5 @@ category=system os_support=solaris *-linux hpux freebsd unixware openbsd irix netbsd macos desc=Disk Quotas depends=mount -longdesc=Setup and edit user or group disk quotas for local filesystems. +longdesc=Setup and edit user, group and Btrfs subvolume quotas for local filesystems. readonly=1 diff --git a/quota/quota-lib.pl b/quota/quota-lib.pl index 265c8e7b3..78e79e288 100755 --- a/quota/quota-lib.pl +++ b/quota/quota-lib.pl @@ -82,6 +82,32 @@ if (defined("a_possible)) { return grep { $_->[4] || $_->[6] } @mtab; } +=head2 list_btrfs_filesystems + +Returns one entry for each mounted Btrfs filesystem when the OS library +provides the Btrfs quota API. Command availability and quota status are checked +separately by callers; on systems without the API, this function returns an +empty list. + +=cut +sub list_btrfs_filesystems +{ +# The OS-specific library determines whether Btrfs quota operations exist. +return ( ) if (!defined(&btrfs_quota_status)); + +# Separately mounted subvolumes share quota state, so keep only the first mount +# for each underlying source while preserving the original display order. +my %seen; +my @filesystems; +foreach my $fs (&mount::list_mounted()) { + next if ($fs->[2] ne "btrfs"); + (my $source = $fs->[1]) =~ s/\[[^\]]*\]$//; + next if ($seen{$source}++); + push(@filesystems, $fs); + } +return @filesystems; +} + =head2 parse_options(type, options) Convert an options string for some filesystem into the global hash %options. @@ -412,6 +438,20 @@ foreach $fs (split(/\s+/, $access{'filesys'})) { return 0; } +=head2 can_edit_btrfs_filesys(filesys) + +Returns 1 if the current Webmin user can manage Btrfs quotas. Btrfs quota state +and qgroup IDs belong to the whole underlying filesystem, so a mount-path ACL +cannot safely confine access to one separately mounted subvolume. + +=cut +sub can_edit_btrfs_filesys +{ +my ($filesys) = @_; +return 0 if (!&can_edit_filesys($filesys)); +return scalar(grep { $_ eq "*" } split(/\s+/, $access{'filesys'})) ? 1 : 0; +} + =head2 can_edit_user(user) Returns 1 if the current Webmin user can manage quotas for some Unix user. diff --git a/quota/save_btrfs.cgi b/quota/save_btrfs.cgi new file mode 100755 index 000000000..7ec2ef454 --- /dev/null +++ b/quota/save_btrfs.cgi @@ -0,0 +1,86 @@ +#!/usr/local/bin/perl +# Save the limits for a Btrfs qgroup + +require './quota-lib.pl'; +&ReadParse(); +$dir = $in{'dir'}; + +# Require write access to an allowed mounted Btrfs filesystem and reject +# malformed qgroup IDs before parsing or applying limits. +$access{'ro'} && &error($text{'btrfs_eedit'}); +&can_edit_btrfs_filesys($dir) || &error($text{'btrfs_eallow'}); +defined(&btrfs_quota_status) && &is_btrfs_fs($dir) || + &error($text{'btrfs_enotbtrfs'}); +&valid_btrfs_qgroup_id($in{'qgroup'}) || &error($text{'btrfs_eqgroup'}); +$in{'qgroup'} eq "0/5" && &error($text{'btrfs_etoplevel'}); +&error_setup($text{'btrfs_esave'}); + +# parse_limit(name) +# Parse one optional byte limit from quota_input and validate its unit factor. +sub parse_limit +{ +my ($name) = @_; + +# A selected default means that this limit should be removed. +return undef if ($in{$name."_def"}); + +# Accept only positive decimal values and units offered by ui_bytesbox. +$in{$name} =~ /^\d+(?:\.\d+)?$/ && $in{$name} > 0 || + &error($text{'btrfs_elimit'}); +local %units = map { $_, 1 } ( 1, 1024, 1024**2, 1024**3, + 1024**4, 1024**5 ); +$units{$in{$name."_units"}} || &error($text{'btrfs_elimit'}); +return int($in{$name} * $in{$name."_units"}); +} + +# Parse both limits completely before making either filesystem change. +$max_referenced = &parse_limit("max_referenced"); +$max_exclusive = &parse_limit("max_exclusive"); + +# Apply the existing ACL ceiling, converting its KiB value to bytes. +if ($access{'maxblocks'}) { + $maxbytes = $access{'maxblocks'} * 1024; + defined($max_referenced) && $max_referenced <= $maxbytes && + defined($max_exclusive) && $max_exclusive <= $maxbytes || + &error(&text('btrfs_emax', &nice_size($maxbytes))); + } + +# Refresh the selected qgroup so unchanged limits are not re-applied. This +# lookup only needs the stored limit values, so no filesystem sync is needed. +$qgroups = &list_btrfs_qgroups($dir, 0, \$listerr); +&error($listerr) if (!$qgroups); +($qgroup) = grep { $_->{'id'} eq $in{'qgroup'} } @$qgroups; +$qgroup || &error($text{'btrfs_eqgroup'}); + +# same_limit(first, second) +# Returns true when two optional byte limits are identical. +sub same_limit +{ +my ($first, $second) = @_; +return !defined($first) && !defined($second) || + defined($first) && defined($second) && $first == $second; +} + +# Compare the submitted referenced and exclusive limits with their current +# values before running either Btrfs command. +$same_referenced = &same_limit( + $max_referenced, $qgroup->{'max_referenced'}); +$same_exclusive = &same_limit( + $max_exclusive, $qgroup->{'max_exclusive'}); + +# Apply only changed limits, keeping an unrelated existing over-limit setting +# from causing Btrfs to reject an otherwise valid update. +if (!$same_referenced) { + $err = &set_btrfs_qgroup_limit( + $dir, $in{'qgroup'}, $max_referenced, 0); + &error($err) if ($err); + } +if (!$same_exclusive) { + $err = &set_btrfs_qgroup_limit( + $dir, $in{'qgroup'}, $max_exclusive, 1); + &error($err) if ($err); + } + +# Log the completed update and return to the qgroup list. +&webmin_log("save", "btrfs", $in{'qgroup'}, \%in); +&redirect("list_btrfs.cgi?dir=".&urlize($dir)); diff --git a/quota/t/run-tests.t b/quota/t/run-tests.t index 361fd30fb..5939a9bda 100644 --- a/quota/t/run-tests.t +++ b/quota/t/run-tests.t @@ -5,6 +5,8 @@ no warnings 'once'; use Test::More; use Cwd qw(abs_path); use File::Basename qw(dirname); +use File::Path qw(make_path); +use File::Temp qw(tempdir); my $root = abs_path(dirname(__FILE__)."/../..") or die "rootdir: $!"; my @commands; @@ -71,6 +73,9 @@ return @{$main::mounted[0]}; do "$root/quota/linux-lib.pl" or die "linux-lib.pl: $@ $!"; +# Device-less tmpfs quota options must not create unusable filesystem rows. +is(main::quota_can([ "/tmp", "tmpfs", "tmpfs", "rw,usrquota" ], undef), + 0, "tmpfs quota mount options are ignored"); ok(main::is_btrfs_fs("/srv/btrfs"), "Btrfs mount point is detected"); ok(main::is_btrfs_fs("/srv/btrfs/domain1"), @@ -132,6 +137,27 @@ is(scalar(@commands), 1, "successful status does not run fallback"); $status = main::btrfs_quota_status("/srv/btrfs"); ok($status->{'enabled'}, "legacy qgroup fallback detects enabled quotas"); +my $sysfs = tempdir(CLEANUP => 1); +my $fsuuid = "12345678-1234-1234-1234-123456789abc"; +make_path("$sysfs/$fsuuid/qgroups"); +foreach my $pair ([ 'enabled', 1 ], [ 'mode', 'squota' ], + [ 'inconsistent', 1 ]) { + open(my $fh, '>', "$sysfs/$fsuuid/qgroups/$pair->[0]") or die $!; + print {$fh} "$pair->[1]\n"; + close($fh); + } +local $main::btrfs_sysfs_root = $sysfs; +@responses = ( + { 'out' => "ERROR: unknown token 'status'\n", 'status' => 1 }, + { 'out' => "qgroupid rfer excl\n0/5 16384 16384\n", 'status' => 0 }, + { 'out' => "Label: none uuid: $fsuuid\n", 'status' => 0 }, + ); +$status = main::btrfs_quota_status("/srv/btrfs"); +is($status->{'mode'}, 'squota', + "legacy fallback reads simple-quota mode from sysfs"); +ok($status->{'inconsistent'}, + "legacy fallback reads inconsistent accounting from sysfs"); + @responses = ( { 'out' => "ERROR: unknown token 'status'\n", 'status' => 1 }, { 'out' => "ERROR: quota root does not exist\n", 'status' => 1 }, @@ -173,6 +199,27 @@ ok(!defined($list_error), "successful qgroup list clears the error"); like($commands[0], qr/qgroup show .*\\-\\-sync .*srv.*btrfs/, "synchronized qgroup listing requests --sync"); +my $legacy_qgroup_text = <<'EOF'; +Qgroupid Referenced Exclusive Max_referenced Max_exclusive Parent Child +0/256 16384 16384 67108864 none 1/100 - +0/257 0 0 none 33554432 1/100 - +1/100 16384 16384 100663296 none - 0/256,0/257 +EOF +@commands = ( ); +@responses = ( + { 'out' => $legacy_qgroup_text, 'status' => 0 }, + { 'out' => "ID 256 gen 10 top level 5 path domain1\n". + "ID 257 gen 11 top level 5 path domain path two\n", + 'status' => 0 }, + ); +$qgroups = main::list_btrfs_qgroups("/srv/btrfs", 0, \$list_error); +is($qgroups->[0]->{'path'}, "domain1", + "legacy qgroup rows gain paths from the subvolume list"); +is($qgroups->[1]->{'path'}, "domain path two", + "legacy subvolume paths containing spaces and path are preserved"); +like($commands[1], qr/subvolume list .*srv.*btrfs/, + "legacy qgroup output triggers one compatibility lookup"); + @responses = ({ 'out' => "ERROR: quotas not enabled\n", 'status' => 1 }); $qgroups = main::list_btrfs_qgroups("/srv/btrfs", 0, \$list_error); ok(!defined($qgroups), "failed qgroup listing returns undef"); @@ -240,6 +287,31 @@ is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", "1M"), is(main::assign_btrfs_qgroup("/srv/btrfs", "bad", "1/100"), "Invalid child Btrfs qgroup ID", "invalid child assignment is rejected"); +# An existing over-limit qgroup must not prevent an administrator from raising +# or removing its limit. The kernel override is restored after the retry. +my $override_root = tempdir(CLEANUP => 1); +my $override_uuid = "abcdef01-2345-6789-abcd-ef0123456789"; +make_path("$override_root/$override_uuid"); +open(my $override_fh, '>', + "$override_root/$override_uuid/quota_override") or die $!; +print {$override_fh} "0\n"; +close($override_fh); +local $main::btrfs_sysfs_root = $override_root; +@commands = ( ); +@responses = ( + { 'out' => "ERROR: unable to limit requested quota group: ". + "Disk quota exceeded\n", 'status' => 1 }, + { 'out' => "Label: none uuid: $override_uuid\n", 'status' => 0 }, + { 'out' => "", 'status' => 0 }, + ); +is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", 2097152), + undef, "over-limit qgroup updates retry with the administrative override"); +open($override_fh, '<', "$override_root/$override_uuid/quota_override") + or die $!; +is(<$override_fh>, "0\n", "quota enforcement is restored after the retry"); +close($override_fh); +is(scalar(@commands), 3, "one status lookup and one limit retry are run"); + @responses = ({ 'out' => "ERROR: qgroup exists\n", 'status' => 1 }); is(main::create_btrfs_qgroup("/srv/btrfs", "1/100"), "ERROR: qgroup exists", "Btrfs command errors are returned to callers");