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 @@
+
+
+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");