#! /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 = ; 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; }