Merge pull request #2807 from webmin/feature/btrfs-subvolume-quotas
Some checks failed
Tests / prove (push) Has been cancelled
Package and upload artifacts / build (push) Has been cancelled
Close inactive / close-inactive (push) Has been cancelled

Add Btrfs subvolume quota management
This commit is contained in:
Jamie Cameron
2026-08-09 11:13:24 -07:00
committed by GitHub
16 changed files with 809 additions and 11 deletions

View File

@@ -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
* Fixed creation of permissions new log files in the System Logs module (thanks to Kevin Carter)
* Update the Authentic theme to the latest version with various improvements:

61
quota/btrfs_action.cgi Executable file
View File

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

View File

@@ -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

View File

@@ -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,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

18
quota/config_info.pl Executable file
View File

@@ -0,0 +1,18 @@
# Hide Btrfs-specific configuration when it cannot be used.
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);
}
1;

54
quota/edit_btrfs.cgi Executable file
View File

@@ -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 "<p>$text{'btrfs_edit_info'}</p>\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'},
&quota_input("max_referenced",
defined($qgroup->{'max_referenced'}) ?
$qgroup->{'max_referenced'} : 0, 1));
print &ui_table_row($text{'btrfs_max_exclusive'},
&quota_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'});

57
quota/help/btrfs.html Normal file
View File

@@ -0,0 +1,57 @@
<header>Btrfs Subvolume Quotas</header>
<h3>Introduction</h3>
Btrfs quotas control disk usage for subvolumes through quota groups, usually
called <tt>qgroups</tt>. 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. <p>
Each Btrfs subvolume has a level-0 <tt>qgroup</tt>. The module displays the
following usage and limit values for each <tt>qgroup</tt> :
<dl>
<dt><b>Referenced</b>
<dd>All data reachable from the subvolume, including data shared with other
subvolumes or snapshots.
<dt><b>Exclusive</b>
<dd>Data used only by the subvolume, which would be freed if it were deleted.
<dt><b>Referenced limit</b>
<dd>The maximum referenced space that the <tt>qgroup</tt> may use.
<dt><b>Exclusive limit</b>
<dd>The maximum exclusive space that the <tt>qgroup</tt> may use.
</dl>
<h3>Accounting Modes</h3>
When Btrfs quotas are enabled, the accounting mode configured in the module
settings is used :
<dl>
<dt><b>Full accounting</b>
<dd>Tracks shared space between subvolumes and snapshots. This is the
recommended mode when accurate referenced and exclusive usage is required.
<dt><b>Simple accounting</b>
<dd>Tracks original ownership with lower overhead, but does not fully track
space shared between subvolumes and snapshots.
</dl>
In simple accounting mode, both values show space assigned to the subvolume
that first wrote the data. <p>
Changing the module setting does not convert an already-enabled filesystem.
The selected mode is used the next time quotas are enabled. <p>
<h3>Managing Btrfs Quotas</h3>
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 <tt>qgroups</tt>, usage, and limits. Click a <tt>qgroup</tt> ID
to edit its referenced and exclusive limits. The top-level <tt>qgroup</tt>
<tt>0/5</tt> is shown for information only because limiting it could stop
filesystem changes. <p>
Because quota state and <tt>qgroup</tt> 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. <p>
Full accounting also provides a rescan action for rebuilding <tt>qgroup</tt>
accounting in the background. Disabling Btrfs quotas removes all
<tt>qgroup</tt> configuration and limits on the filesystem, so the module
always requests confirmation first. <p>
<hr>

View File

@@ -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 = &quotas_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 = &quotas_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 "<p><b>$err</b><p>\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 "<b>$text{'index_nosupport'}</b><p>\n";
if (&foreign_available("mount")) {
print &text('index_mountmod', "../mount/"),"<p>\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();

View File

@@ -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(&quotas_init)) {
local $err = &quotas_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;
}

View File

@@ -167,6 +167,56 @@ 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 <a href='$1'>Disk and Network Filesystems</a> 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
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 <tt>btrfs-progs</tt>
btrfs_consistency=Accounting state
btrfs_consistent=Consistent
btrfs_inconsistent=Inconsistent - a rescan is recommended
btrfs_qgroups=Subvolume quota groups
btrfs_qgroup=<tt>qgroup</tt>
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 <tt>qgroup</tt> accounting in the background.
btrfs_edit_title=Edit Btrfs Quota
btrfs_edit_header=Limits for <tt>qgroup</tt> <tt>$1</tt> on <tt>$2</tt>
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
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 <tt>qgroup</tt> does not exist
btrfs_etoplevel=The top-level Btrfs <tt>qgroup</tt> 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

View File

@@ -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+)(?=.*\[\/)\'') );
@@ -1105,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<btrfs qgroup show> 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 =~ /^</);
$root ||= "/";
$root =~ s/^\/+//;
$root =~ s/\/+\z//;
$path =~ s/^\/+//;
# Strip the mounted subvolume root from the filesystem-relative qgroup path.
if ($root ne "") {
return undef if ($path ne $root && index($path, "$root/") != 0);
$path = substr($path, length($root));
$path =~ s/^\/+//;
}
$mount =~ s/\/+\z// if ($mount ne "/");
my $absolute = $path eq "" ? ($mount || "/") :
($mount eq "/" ? "/$path" : "$mount/$path");
$absolute =~ s{//+}{/}g;
return $absolute;
}
# valid_btrfs_path(path)
# Returns 1 for an absolute path that is safe to pass to Btrfs tools.
sub valid_btrfs_path
@@ -1168,6 +1252,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 +1325,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 =~ /<squota space holder>/i);
return $rv;
}
elsif ($qerr =~ /(?:quota root does not exist|quotas? (?:are |is )?not enabled)/i) {
return { 'supported' => 1,
@@ -1243,6 +1386,27 @@ foreach my $line (split(/\r?\n/, $out)) {
return \@rv;
}
=head2 parse_btrfs_subvolume_list_output(output)
Parses raw output from C<btrfs subvolume list> 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 <path relative to top level>".
# 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 +1436,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;
}

91
quota/list_btrfs.cgi Executable file
View File

@@ -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'});

View File

@@ -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

View File

@@ -82,6 +82,32 @@ if (defined(&quota_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.

86
quota/save_btrfs.cgi Executable file
View File

@@ -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 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);
&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));

View File

@@ -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"),
@@ -82,6 +87,23 @@ ok(!main::is_btrfs_fs("/"),
ok(!defined(main::btrfs_quota_status("/")),
"quota status is unavailable for non-Btrfs paths");
my $mountinfo = <<'EOF';
24 1 0:20 / / rw,relatime - ext4 /dev/root rw
31 24 0:42 /@home /home rw,relatime - btrfs /dev/vdb rw,compress=zstd
32 31 0:42 /@home/example/homes/bob /srv/bob rw,relatime - btrfs /dev/vdb rw,compress=zstd
EOF
my ($mount, $fsroot) = main::parse_btrfs_mountinfo(
$mountinfo, "/home/example/homes/alice");
is($mount, "/home", "containing Btrfs mount is selected");
is($fsroot, '/@home', "mounted Btrfs filesystem root is returned");
is(main::btrfs_qgroup_absolute_path(
$mount, $fsroot, '@home/example/homes/alice'),
"/home/example/homes/alice",
"qgroup path is translated through a mounted subvolume root");
ok(!defined(main::btrfs_qgroup_absolute_path(
$mount, $fsroot, '@var/lib/mysql')),
"qgroups outside the mounted filesystem root are ignored");
my $status_text = <<'EOF';
Quotas on /srv/btrfs:
Enabled: yes
@@ -132,6 +154,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 +216,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 +304,13 @@ 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");
@commands = ( );
@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"),
"ERROR: qgroup exists", "Btrfs command errors are returned to callers");