the-bastion/bin/admin/check_uid_gid_collisions.pl
Stéphane Lesimple 7934b6283b Update bin/admin/check_uid_gid_collisions.pl
Co-authored-by: Adrien Barreau <adrien.barreau@live.fr>
2023-10-04 17:55:07 +02:00

326 lines
12 KiB
Perl
Executable file

#! /usr/bin/env perl
# vim: set filetype=perl ts=4 sw=4 sts=4 et:
use strict;
use warnings;
use 5.010;
use Getopt::Long;
use File::Basename;
use lib dirname(__FILE__) . '/../../lib/perl';
my $getOptionsOk = GetOptions(
"master-passwd=s" => \my $masterPasswdFile,
"local-passwd=s" => \my $localPasswdFile,
"master-group=s" => \my $masterGroupFile,
"local-group=s" => \my $localGroupFile,
"output=s" => \my $outputFile,
"offset=i" => \my $offset, # undocumented on purpose
);
if (!$getOptionsOk) {
die "Failed to parse options (see above), aborting.\n";
}
$localPasswdFile //= '/etc/passwd';
$localGroupFile //= '/etc/group';
$offset //= 50_000_000;
if ($offset < 10_000) {
die "Offset is too low ($offset)\n";
}
if (!$masterGroupFile || !$masterPasswdFile || !$outputFile) {
die "Usage: $0 --master-passwd PATH --master-group PATH --output FILE [--local-passwd PATH --local-group PATH]\n";
}
if (-e $outputFile && !-c _) {
die "Output file '$outputFile' already exists!\n";
}
my (%local, %master);
read_pwgr_file($masterPasswdFile, \%master, 'pw');
read_pwgr_file($localPasswdFile, \%local, 'pw');
read_pwgr_file($masterGroupFile, \%master, 'gr');
read_pwgr_file($localGroupFile, \%local, 'gr');
sub read_pwgr_file {
my ($file, $hashref, $type) = @_;
my $fh;
if (!open($fh, '<', $file)) {
die("Couldn't open file '$file': $!");
}
$hashref->{$type} = {};
while (<$fh>) {
chomp;
my ($name, undef, $id) = split /:/;
next if $id == 0; # never mess with UID/GID 0
$hashref->{$type}{name_by_id}{$id} = $name;
$hashref->{$type}{id_by_name}{$name} = $id;
}
close($fh);
return;
}
my $orphans;
# first, report orphans
handle_list($local{'pw'}, $master{'pw'}, 'pw', 0);
handle_list($local{'gr'}, $master{'gr'}, 'gr', 0);
if ($orphans) {
say "\nThere is at least one warning, see above.";
say "If you want to handle them, you may still abort now.";
say "Type 'YES' to proceed regardless.";
my $ans = <STDIN>;
chomp $ans;
if ($ans ne 'YES') {
say "Aborting on user request.";
exit 0;
}
else {
say "";
}
}
# build the list of mountpoints on which we'll run chmod/chown
my @fslist;
if (open(my $fh, '<', '/proc/mounts')) {
while (<$fh>) {
# /dev/loop9 /snap/cups/872 squashfs ro,nodev,relatime,errors=continue 0 0
my @fields = split / /;
# ignore some filesystems
next
if (
grep { $fields[2] eq $_ }
qw{
squashfs vfat cgroup cgroup2 devpts devtmpfs proc fuse.pathfs.pathInode
cifs nfs nsfs rpc_pipefs pstore autofs debugfs configfs fusectl binfmt_misc
mqueue securityfs sysfs efivarfs bpf hugetlbfs tracefs overlay overlay2 aufs
}
);
# ignore some mountpoints we know we want to exclude anyway
next if ($fields[1] =~ m{^/(dev|sys|proc|snap|boot)(/|\z)});
# ignore readonly mountpoints
next if ($fields[3] =~ m{(^|,)ro(,|$)});
# ok add this mountpoint
push @fslist, $fields[1];
}
}
else {
die "Couldn't open /proc/mounts: $!";
}
my @cmds;
my @grepcmds;
while (!handle_list($local{'pw'}, $master{'pw'}, 'pw', 1)) {
;
}
while (!handle_list($local{'gr'}, $master{'gr'}, 'gr', 1)) {
;
}
while (!handle_list($local{'pw'}, $master{'pw'}, 'pw', 2)) {
;
}
while (!handle_list($local{'gr'}, $master{'gr'}, 'gr', 2)) {
;
}
if (!@cmds) {
say "There is nothing to change, all UIDs/GIDs are in sync :)";
exit 0;
}
else {
say "";
}
if (open(my $fh, '>', $outputFile)) {
my $fslist_flat = join(" ", sort @fslist);
my $grepcmds_flat = join("\n", @grepcmds);
my $cmds_flat = join("\n", @cmds);
print $fh <<"EOF1", <<'EOF2', $cmds_flat, <<'EOF3';
#!/bin/bash
# You may change the list below if needed:
fslist="$fslist_flat"
# Don't change anything below this line (unless you know what you're doing!)
proclist=\$(true ; $grepcmds_flat)
EOF1
echo "We'll change the UIDs/GIDs of files, when needed, in the following mountpoints: $fslist"
echo "If you'd like to change this list, please edit this script and change the 'fslist' variable in the header."
echo "Otherwise, if this sounds reasonable (e.g. there is no remotely mounted filesystem that you don't want us to touch), say 'YES' below:"
read answer
if [ "$answer" != YES ]; then
echo "Aborting on user request."
exit 0
fi
proclist=$(echo $proclist | tr " " "\n" | sort -u)
if [ -n "$proclist" ]; then
pscmd="ps fu"
for pid in $proclist; do
pscmd="$pscmd -p $pid"
done
echo
echo "The following processes/daemons will need to be killed before swapping the UIDs/GIDs:"
echo
$pscmd
echo
echo "If you want to stop them manually, you may abort now (CTRL+C) and do so."
echo "Press ENTER to continue."
read __
fi
echo "Listing SUID/SGID files before potentially altering those..."
suidfiles=$(mktemp)
sgidfiles=$(mktemp)
trap "rm -f $suidfiles $sgidfiles" EXIT
find $fslist -xdev -ignore_readdir_race -perm /4000 -type f -print0 > "$suidfiles"
find $fslist -xdev -ignore_readdir_race -perm /2000 -type f -print0 > "$sgidfiles"
echo "Starting work"
echo
EOF2
echo
echo "Restoring SUID/SGID flags where needed..."
xargs -r0 chmod -v u+s -- < "$suidfiles"
xargs -r0 chmod -v g+s -- < "$sgidfiles"
echo
echo 'UID/GID swapping done, please reboot now.'
echo
EOF3
close($fh);
say "\nYou may now review the generated script ($outputFile) and launch it when you're ready.";
say "Note that you'll have to reboot once the script has completed.";
}
else {
die "Couldn't open '$outputFile' for write: $!";
}
exit 0;
sub handle_list {
my ($local, $master, $type, $pass) = @_;
my ($ch, $idtype, $typename);
if ($type eq 'pw') {
$ch = 'chown';
$idtype = 'UID';
$typename = 'account';
}
elsif ($type eq 'gr') {
$ch = 'chgrp';
$idtype = 'GID';
$typename = 'group';
}
else {
die "Unknown type '$type'";
}
my %unseen_ids = map { $_ => 1 } keys %{$local->{'name_by_id'}};
my %unseen_names = map { $_ => 1 } keys %{$local->{'id_by_name'}};
my $fullsync = 0;
my $tocreate = 0;
# loop through the master ids, because master is always right
foreach my $master_id (sort keys %{$master->{'name_by_id'}}) {
delete $unseen_ids{$master_id};
my $master_name = $master->{'name_by_id'}{$master_id};
delete $unseen_names{$master_name};
next if ($pass == 0);
# does this master ID exists locally?
if (exists $local->{'name_by_id'}{$master_id}) {
# yes: but is this the same name?
my $local_name = $local->{'name_by_id'}{$master_id};
if ($local_name eq $master_name) {
# yes: ok, both IDs and names match, we're done
$fullsync++;
}
else {
# no: name collision, we have two different names for the same ID
printf("Name collision on $idtype: master $idtype %d exists on local "
. "but with a different name (master=%s local=%s)\n",
$master_id, $master_name, $local_name);
if ($pass == 1) {
# in that case, on first pass, we push the local ID way higher to ensure it is out of the way
my $new_local_id = $master_id + $offset;
$local->{'name_by_id'}{$new_local_id} = delete $local->{'name_by_id'}{$master_id};
$local->{'id_by_name'}{$local_name} = $new_local_id;
push @cmds, "echo '*** ${typename}s: step 1: offsetting $local_name to $new_local_id'";
push @cmds,
sprintf("find \$fslist -xdev -ignore_readdir_race -%s %d -ls -exec %s %d '{}' +",
lc($idtype), $master_id, $ch, $new_local_id);
push @cmds, sprintf("pkill -%s %s && sleep 1", $type eq 'pw' ? 'U' : 'G', $local_name);
push @cmds, sprintf("usermod --uid %d %s", $new_local_id, $local_name) if $type eq 'pw';
push @cmds, sprintf("groupmod --gid %d %s", $new_local_id, $local_name) if $type eq 'gr';
push @grepcmds, sprintf("pgrep -%s %s", $type eq 'pw' ? 'U' : 'G', $local_name);
# and we ask our caller to call us again as we've changed the local ID for this account or group
say "-> okay, offsetting local UID $master_id to $new_local_id";
return 0;
}
else {
die "Should not happen";
}
}
}
else {
# no: this master ID doesn't exists locally, but do we have a local name corresponding to the master one?
my $local_id = $local->{'id_by_name'}{$master_name};
if (defined $local_id) {
# yes: differing name for same ID
# don't report if $local_id is > $offset in pass 1, because it's a transient situation WE created,
# and we know about it
if ($pass == 2 || $local_id < $offset) {
printf("Differing name attached to same $idtype: master $idtype %s doesn't exist on local, "
. "but its corresponding name '%s' does, with local $idtype %d\n",
$master_id, $master_name, $local_id);
}
if ($pass == 2) {
# on second pass we know the master ID is now available locally, so do the change
$local->{'name_by_id'}{$master_id} = delete $local->{'name_by_id'}{$local_id};
$local->{'id_by_name'}{$master_name} = $master_id;
push @cmds,
"echo '*** ${typename}s: step 2: setting back $master_name to $master_id instead of $local_id'";
push @cmds,
sprintf("find \$fslist -xdev -ignore_readdir_race -%s %d -ls -exec %s %d '{}' +",
lc($idtype), $local_id, $ch, $master_id);
push @cmds, sprintf("pkill -%s %s && sleep 1", $type eq 'pw' ? 'U' : 'G', $master_name);
push @cmds, sprintf("usermod --uid %d %s", $master_id, $master_name) if $type eq 'pw';
push @cmds, sprintf("groupmod --gid %d %s", $master_id, $master_name) if $type eq 'gr';
push @grepcmds, sprintf("pgrep -%s %s", $type eq 'pw' ? 'U' : 'G', $master_name);
say "-> okay, setting local $idtype of $master_name to $master_id instead of $local_id";
# and we ask our caller to call us again as we've changed the local ID for this account or group
return 0;
}
}
else {
# no: ok, this new entry will be created locally
$tocreate++;
}
}
}
if ($pass == 0) {
# loop through the unseen IDs we have locally, and set their corresponding name as 'seen' (delete from unseen)
foreach my $local_id (sort keys %unseen_ids) {
next if $local_id >= $offset;
my $local_name = $local->{'name_by_id'}{$local_id};
delete $unseen_names{$local_name};
}
# loop through the unseen names we have locally, and report: they would be erased
foreach my $local_name (sort keys %unseen_names) {
my $local_id = $local->{'id_by_name'}{$local_name};
next if $local_id >= $offset;
printf("WARN: local orphan name: local name '%s' (with $idtype %d) is only present locally, "
. "if you want to keep it, create it on the master first or it'll be erased\n",
$local_name, $local_id);
$orphans++;
}
}
return 1;
}