From 9be5dc31ebc083feaeb6188670612c4e6b464e7b Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 4 Aug 2026 01:19:45 +0200 Subject: [PATCH 1/7] Add Btrfs subvolume quota management Add Btrfs subvolume quota support to the Disk Quotas module, including full and simple accounting modes, quota lifecycle actions, and qgroup usage and limit management. Includes ACL enforcement, compatibility with older btrfs-progs versions, destructive-action confirmation, top-level qgroup protection, help, and tests. --- CHANGELOG.md | 1 + quota/btrfs_action.cgi | 61 +++++++++++++++++ quota/config-ALL-linux | 1 + quota/config.info | 1 + quota/config_info.pl | 36 ++++++++++ quota/edit_btrfs.cgi | 54 +++++++++++++++ quota/help/btrfs.html | 53 +++++++++++++++ quota/index.cgi | 95 ++++++++++++++++++++++++-- quota/install_check.pl | 8 ++- quota/lang/en | 53 +++++++++++++++ quota/linux-lib.pl | 148 +++++++++++++++++++++++++++++++++++++++-- quota/list_btrfs.cgi | 91 +++++++++++++++++++++++++ quota/module.info | 2 +- quota/quota-lib.pl | 40 +++++++++++ quota/save_btrfs.cgi | 86 ++++++++++++++++++++++++ quota/t/run-tests.t | 72 ++++++++++++++++++++ 16 files changed, 789 insertions(+), 13 deletions(-) create mode 100755 quota/btrfs_action.cgi create mode 100755 quota/config_info.pl create mode 100755 quota/edit_btrfs.cgi create mode 100644 quota/help/btrfs.html create mode 100755 quota/list_btrfs.cgi create mode 100755 quota/save_btrfs.cgi 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"); From 65e171a92ff46cc752617d39fbbc467acd918f65 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 4 Aug 2026 01:58:27 +0200 Subject: [PATCH 2/7] Fix to use module config https://github.com/webmin/webmin/pull/2807#pullrequestreview-4849404584 --- quota/config.info | 2 +- quota/config_info.pl | 20 +------------------- quota/lang/en | 3 --- 3 files changed, 2 insertions(+), 23 deletions(-) diff --git a/quota/config.info b/quota/config.info index 28e1456d8..df6bcdd03 100644 --- a/quota/config.info +++ b/quota/config.info @@ -5,7 +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 +btrfs_mode=Btrfs accounting mode when enabling quotas,4,full-Full accounting, recommended for shared space and snapshots,simple-Simple accounting, lower overhead with original ownership tracking 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 index e879f6d35..b9abbf0d0 100755 --- a/quota/config_info.pl +++ b/quota/config_info.pl @@ -1,4 +1,4 @@ -# Build and parse the Btrfs-specific module configuration field. +# Hide Btrfs-specific configuration when it cannot be used. require './quota-lib.pl'; # config_pre_load(info, [order]) @@ -15,22 +15,4 @@ 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/lang/en b/quota/lang/en index ba00c51f2..6ed06c337 100644 --- a/quota/lang/en +++ b/quota/lang/en @@ -177,9 +177,6 @@ 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 From b433cdd810d7065b1f25eb3366b6b219ebcbdf90 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Tue, 4 Aug 2026 02:04:59 +0200 Subject: [PATCH 3/7] Fix to clarify Btrfs qgroup limit retry behavior https://github.com/webmin/webmin/pull/2807#discussion_r3708456970 --- quota/linux-lib.pl | 13 +++++++------ quota/save_btrfs.cgi | 4 ++-- quota/t/run-tests.t | 8 ++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/quota/linux-lib.pl b/quota/linux-lib.pl index 188c0fb05..f37bf804e 100755 --- a/quota/linux-lib.pl +++ b/quota/linux-lib.pl @@ -1482,9 +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. 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. +is undef, the limit is removed. If quota enforcement on path or one of its +parent qgroups blocks the transaction, it is temporarily overridden for one +retry. Returns undef on success or an error message on failure. =cut sub set_btrfs_qgroup_limit @@ -1502,9 +1502,10 @@ 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. +# A limit on the command path or one of its parent qgroups can block the +# transaction needed to update any qgroup 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"; diff --git a/quota/save_btrfs.cgi b/quota/save_btrfs.cgi index 7ec2ef454..f49a7b97e 100755 --- a/quota/save_btrfs.cgi +++ b/quota/save_btrfs.cgi @@ -68,8 +68,8 @@ $same_referenced = &same_limit( $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. +# Apply only changed limits so each independent setting is left untouched when +# the submitted value already matches it. if (!$same_referenced) { $err = &set_btrfs_qgroup_limit( $dir, $in{'qgroup'}, $max_referenced, 0); diff --git a/quota/t/run-tests.t b/quota/t/run-tests.t index 5939a9bda..3485f61f4 100644 --- a/quota/t/run-tests.t +++ b/quota/t/run-tests.t @@ -287,8 +287,8 @@ 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. +# A qgroup-limit transaction rejected by quota enforcement is retried once, +# and the kernel override is restored immediately afterward. my $override_root = tempdir(CLEANUP => 1); my $override_uuid = "abcdef01-2345-6789-abcd-ef0123456789"; make_path("$override_root/$override_uuid"); @@ -305,12 +305,12 @@ local $main::btrfs_sysfs_root = $override_root; { 'out' => "", 'status' => 0 }, ); is(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", 2097152), - undef, "over-limit qgroup updates retry with the administrative override"); + undef, "an EDQUOT limit update retries 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"); +is(scalar(@commands), 3, "one UUID lookup and one limit retry are run"); @responses = ({ 'out' => "ERROR: qgroup exists\n", 'status' => 1 }); is(main::create_btrfs_qgroup("/srv/btrfs", "1/100"), From 2918ac42040c8e85fa4d6a261c25f24d4a79abbb Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Wed, 5 Aug 2026 01:25:58 +0200 Subject: [PATCH 4/7] Add missing API --- quota/linux-lib.pl | 78 +++++++++++++++++++++++++++++++++++++++++++++ quota/t/run-tests.t | 17 ++++++++++ 2 files changed, 95 insertions(+) diff --git a/quota/linux-lib.pl b/quota/linux-lib.pl index f37bf804e..1b7245ef6 100755 --- a/quota/linux-lib.pl +++ b/quota/linux-lib.pl @@ -1111,6 +1111,84 @@ foreach my $m (&mount::list_mounted()) { return $best && $best->[2] eq "btrfs" ? 1 : 0; } +# decode_btrfs_mount_path(path) +# Decodes the octal escapes used by /proc/self/mountinfo. +sub decode_btrfs_mount_path +{ +my ($path) = @_; +$path =~ s/\\([0-7]{3})/chr(oct($1))/eg; +return $path; +} + +# parse_btrfs_mountinfo(text, path) +# Returns the deepest Btrfs mount point containing path and its filesystem root. +sub parse_btrfs_mountinfo +{ +my ($text, $path) = @_; +my ($best_mount, $best_root); +# Parse only Btrfs mountinfo records that can contain the requested path. +foreach my $line (split(/\r?\n/, $text)) { + my ($left, $right) = split(/\s+-\s+/, $line, 2); + next if (!defined($right)); + my @right = split(/\s+/, $right); + next if ($right[0] ne "btrfs"); + my @left = split(/\s+/, $left); + next if (@left < 5); + my $root = &decode_btrfs_mount_path($left[3]); + my $mount = &decode_btrfs_mount_path($left[4]); + next if (!&is_under_directory($mount, $path)); + # Prefer the deepest match when nested Btrfs subvolumes are mounted. + if (!defined($best_mount) || length($mount) > length($best_mount)) { + $best_mount = $mount; + $best_root = $root; + } + } +return defined($best_mount) ? ($best_mount, $best_root) : ( ); +} + +=head2 btrfs_mountinfo(path) + +Returns the visible Btrfs mount point containing a path and its filesystem +root, or an empty list when no containing Btrfs mount can be found. + +=cut +sub btrfs_mountinfo +{ +my ($path) = @_; +open(my $fh, "<", "/proc/self/mountinfo") || return ( ); +local $/ = undef; +my $text = <$fh>; +close($fh); +return &parse_btrfs_mountinfo($text, $path); +} + +=head2 btrfs_qgroup_absolute_path(mount, filesystem-root, qgroup-path) + +Converts the filesystem-relative path reported by C to a +visible absolute path, or returns undef when it is outside the mounted root. + +=cut +sub btrfs_qgroup_absolute_path +{ +my ($mount, $root, $path) = @_; +return undef if (!defined($path) || $path eq "" || $path =~ /^ Date: Fri, 7 Aug 2026 02:29:36 +0200 Subject: [PATCH 5/7] Fix comments --- quota/linux-lib.pl | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/quota/linux-lib.pl b/quota/linux-lib.pl index 1b7245ef6..8c9564375 100755 --- a/quota/linux-lib.pl +++ b/quota/linux-lib.pl @@ -1560,9 +1560,10 @@ 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. If quota enforcement on path or one of its -parent qgroups blocks the transaction, it is temporarily overridden for one -retry. Returns undef on success or an error message on failure. +is undef, the limit is removed. If the qgroup for the subvolume containing +path, or one of its parent qgroups, blocks the transaction, quota enforcement +is temporarily overridden for one retry. Returns undef on success or an error +message on failure. =cut sub set_btrfs_qgroup_limit @@ -1580,10 +1581,11 @@ push(@args, $qgroup) if (defined($qgroup)); push(@args, $path); my ($out, $err) = &run_btrfs_command(1, @args); -# A limit on the command path or one of its parent qgroups can block the -# transaction needed to update any qgroup limit. Retry once with the kernel's -# administrative override, preserving the previous state and restoring -# enforcement immediately after the command. +# If the qgroup for the subvolume containing $path, or one of its parent +# qgroups, is over quota, Btrfs can reject the transaction needed to update any +# qgroup 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"; From b8291f206c4d9a1a6fbc99013ab6206052e33a6b Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Fri, 7 Aug 2026 02:35:28 +0200 Subject: [PATCH 6/7] Fix to clarify Btrfs quota accounting behavior --- quota/help/btrfs.html | 6 +++++- quota/lang/en | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/quota/help/btrfs.html b/quota/help/btrfs.html index 0e80eb9c9..f02a26ca3 100644 --- a/quota/help/btrfs.html +++ b/quota/help/btrfs.html @@ -31,6 +31,8 @@ recommended mode when accurate referenced and exclusive usage is required.

Tracks original ownership with lower overhead, but does not fully track space shared between subvolumes and snapshots. +In simple accounting mode, both values show space assigned to the subvolume +that first wrote the data.

Changing the module setting does not convert an already-enabled filesystem. The selected mode is used the next time quotas are enabled.

@@ -38,7 +40,9 @@ The selected mode is used the next time quotas are enabled.

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.

+to edit its referenced and exclusive limits. The top-level qgroup +0/5 is shown for information only because limiting it could stop +filesystem changes.

Because quota state and qgroup IDs apply to the whole underlying Btrfs filesystem, delegated Webmin users must be allowed to manage all filesystems to diff --git a/quota/lang/en b/quota/lang/en index 6ed06c337..1ef3573b2 100644 --- a/quota/lang/en +++ b/quota/lang/en @@ -204,7 +204,7 @@ 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_edit_info=Full accounting shows space reachable from the subvolume and space freed by deleting it. Simple accounting shows space assigned to the subvolume that first wrote the data. 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 From 29b299c8817bf5766c6f39df58c00f8a6bceb626 Mon Sep 17 00:00:00 2001 From: Ilia Ross Date: Sat, 8 Aug 2026 14:40:03 +0200 Subject: [PATCH 7/7] Fix to avoid overriding Btrfs quota enforcement --- quota/linux-lib.pl | 45 ++------------------------------------------- quota/t/run-tests.t | 28 +++++----------------------- 2 files changed, 7 insertions(+), 66 deletions(-) diff --git a/quota/linux-lib.pl b/quota/linux-lib.pl index 8c9564375..12e4d1d48 100755 --- a/quota/linux-lib.pl +++ b/quota/linux-lib.pl @@ -1560,10 +1560,8 @@ 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. If the qgroup for the subvolume containing -path, or one of its parent qgroups, blocks the transaction, quota enforcement -is temporarily overridden for one retry. Returns undef on success or an error -message on failure. +is undef, the limit is removed. Returns undef on success or an error message +on failure. =cut sub set_btrfs_qgroup_limit @@ -1580,45 +1578,6 @@ push(@args, defined($bytes) ? $bytes : "none"); push(@args, $qgroup) if (defined($qgroup)); push(@args, $path); my ($out, $err) = &run_btrfs_command(1, @args); - -# If the qgroup for the subvolume containing $path, or one of its parent -# qgroups, is over quota, Btrfs can reject the transaction needed to update any -# qgroup 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/t/run-tests.t b/quota/t/run-tests.t index e0512e8f0..cc86becfe 100644 --- a/quota/t/run-tests.t +++ b/quota/t/run-tests.t @@ -304,30 +304,12 @@ 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"); -# A qgroup-limit transaction rejected by quota enforcement is retried once, -# and the kernel override is restored immediately afterward. -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, "an EDQUOT limit update retries 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 UUID lookup and one limit retry are run"); +@responses = ({ 'out' => "ERROR: unable to limit requested quota group: ". + "Disk quota exceeded\n", 'status' => 1 }); +like(main::set_btrfs_qgroup_limit("/srv/btrfs", "1/100", 2097152), + qr/Disk quota exceeded/, "qgroup limit errors are returned without retry"); +is(scalar(@commands), 1, "a failed qgroup limit command is not retried"); @responses = ({ 'out' => "ERROR: qgroup exists\n", 'status' => 1 }); is(main::create_btrfs_qgroup("/srv/btrfs", "1/100"),