1

I have two CentOS systems, I want to do syncing of password hashes (only) which are stored in /etc/shadow, from system A (local) to system B (remote) but only for these users which have UID > 1000 and exist on both systems (basing on username, not UID which can vary for the same username on A an B).

I can't use rsync or solutions like LDAP or NIS. I also can't touch any accounts with UID < 1000 on these systems.

As UID of a user can differ on host A and B - to sync password hash from A to B following is important: (1) the username must exist on both systems (2) the username's UID must be >1000 (can differ) on both A and B systems

I have found a nice perl script of Renaud Bompuis which probably would need some adjustements to my requirements, it should not modify /etc/passwd or /etc/group. I am not a Perl programmer so I am looking for help here. Thank you in advance.

#!/usr/bin/perl -w
use Net::SCP qw(scp);
use strict;

use constant TRUE  => (1==1);
use constant FALSE => (1==0);

#--------------------------------------------------------
# Configuration
# Modify as needed
#--------------------------------------------------------
my $remoteHost = '10.13.113.2';  # email backup server
my $minUID     = 500;
my $maxUID     = 30000;
my $minGID     = 500;
my $maxGID     = 30000;

#--------------------------------------------------------
# Internal variables, normally not to be modified.
#--------------------------------------------------------
my $systemConfigDir = '/etc';
my $tmpDir = $ENV{TMPDIR} || $ENV{TMP} || $ENV{TEMP} || '/tmp';

#--------------------------------------------------------
#  Main
#--------------------------------------------------------
# STEP 1
# Get the remote files to /tmp and
# clean them of their normal users
ProcessFiles('remote');

# STEP 2
# Append the local normal users to the temp files
# and then send them back to the remote
ProcessFiles('local');

#--------------------------------------------------------
# ProcessFiles sub does one of two things:
# - if the passed argument is 'remote', then fetch each
#   user account file from the remote server, then remove
#   all normal users from each file, only keeping the
#   system users.
# - if the passed argument is 'local', then appends all
#   normal local users to the previously fetched and
#   cleaned-up files, then copies them back to the remote.
#--------------------------------------------------------
sub ProcessFiles {
        my $which = shift;
        my $tmpfile;
        my %username = ();
        my %usergroup = ();
        my %userUID = ();
        my %userGID = ();
        my @info;
        foreach my $f ('passwd','group','shadow','gshadow') {
                my $tmpfile = "$tmpDir/$f.REMOTE";
                if ($which eq 'remote') {
                        # Fetch the remote file
                        unlink $tmpfile if -e $tmpfile;
                        scp("$remoteHost:$systemConfigDir/$f", $tmpfile)
                                or die ("Could not get '$f' from '$remoteHost'");
                }
                # Glob the file content
                open CONFIGFILE, (($which eq 'remote') ? $tmpfile : "$systemConfigDir/$f");
                my @lines = <CONFIGFILE>;
                close CONFIGFILE;
                # Open the temp file, either truncating it or in append mode
                open TMPFILE,  (($which eq 'remote') ? ">$tmpfile" : ">>$tmpfile" )
                        or die "Could not open '$tmpfile' for processing";
                foreach my $line (@lines) {
                         # Skip comments, although they should be illegal in these files
                        next if $f =~ /^\s*#/;
                        @info = (split ':', $line);
                        if ($f eq 'passwd') {
                                my $uid = $info[2];
                                my $isnormaluser = ($uid > $minUID) && ($uid < $maxUID);
                                next if (($which eq 'remote') ? $isnormaluser : !$isnormaluser);
                                $username{$info[0]} = TRUE;
                                $userUID{$uid} = TRUE;
                                $userGID{$info[3]} = TRUE;
                        } elsif ($f eq 'group') {
                                my $gid = $info[2];
                                my $isnormalgroup = ($gid > $minGID) && ($gid < $maxGID);
                                next if (($which eq 'remote') ? $isnormalgroup : !$isnormalgroup);
                                $usergroup{$info[0]} = TRUE;
                        } elsif ($f eq 'shadow') {
                                next if !exists $username{$info[0]};
                        } else {
                                next if !exists $usergroup{$info[0]};
                        }
                        # Any line that reaches this point is valid
                        print TMPFILE $line;
                }
                close TMPFILE;
                if ($which eq 'local') {
                        # send the file back
                        scp($tmpfile, "$remoteHost:$systemConfigDir/$f") or
                                die ("Could not send '$f' to '$remoteHost'");
                        unlink $tmpfile;
                }
        }
}

#--------------------------------------------------------
# Make sure we cleanup the temp files when we exit
#--------------------------------------------------------
END {
        my $tmpfile;
        foreach my $f ('passwd','group','shadow','gshadow') {
                $tmpfile = "$tmpDir/$f.REMOTE";
                unlink $tmpfile if -e $tmpfile;
        }
}
  • I don't see necessity to copy a user password from system A to B if the user does not exist on B. Of course when such a user is one day created on system B, then his password should be synced from A to B. – DonJ Mar 6 '18 at 21:48
  • You refer to UID<1000 and UID>1000. What about UID=1000? – roaima Mar 6 '18 at 22:37
1

Based on @roaiama's use of the join command, this answer uses getent to get the passwd and shadow files rather than reading them directly, and then uses chpasswd on the remote host to change the passwords.

The password-changing code is simpler because of chpasswd, but making a backup copy of the old shadow entries is a little more complicated because we're using getent shadow on the remote host too.

join -t : -j 1 -o 2.{1..2} \
    <(getent passwd | awk -F: '$3 > 1000 {print $1}' | sort) \
    <(getent shadow | sort) | 
  ssh remotehost 'umask 0027 &&
    getent shadow > /etc/shadow.old &&
    chgrp shadow /etc/shadow.old &&
    chpasswd -e 2>/dev/null'

It pipes only the first two fields, the username and the encrypted password (output format is one "username:password" pair per line), into ssh. After making a backup copy of the old shadow file, the remote shell runs chpasswd to change the passwords as specified on stdin.

The -e option tells chpasswd that the passwords are already encrypted. Without that option, it would re-encrypt the supplied passwords.

chpasswd will complain on stderr about any usernames that don't exist on the remote system, but will still change the passwords for usernames that do exist. chpasswd's stderr can be redirected to /dev/null as i've shown above.

NOTE: It would be better to pipe stderr into a script that drops only the expected & harmless "username doesn't exist" errors while still displaying other errors. On my testing VM, the errors output by chpasswd for non-existent users look like this:

# printf '%s\n' "foo:bar" "xyzzy:fool" | chpasswd
chpasswd: (user foo) pam_chauthtok() failed, error:
Authentication token manipulation error
chpasswd: (line 1, user foo) password not changed
chpasswd: (user xyzzy) pam_chauthtok() failed, error:
Authentication token manipulation error
chpasswd: (line 2, user xyzzy) password not changed
  • Join your input to chpasswd with the first column from getent passwd to remove the "user not found" issue. – roaima Mar 10 '18 at 13:43
  • yes, that would work. or exclude UIDs <= 1000 again. something like join -t : -j 1 -o 2.{1..2} <(getent passwd | awk -F: '$3 > 1000 {print $1}' | sort) - | chpasswd -e as the last line. – cas Mar 10 '18 at 23:38
1

This will synchronise the entries in /etc/shadow from the local system to the remote system (here called remotehost) for all user accounts with UID > 1000 that exist on both systems:

getent passwd |
    awk -F: '$3>1000 {print $1}' |
    sort |
    join -t : -j 1 -o 2.{1..9} - <(getent shadow | sort) |
    ssh remotehost '
        cp -fp /etc/shadow /etc/shadow.old &&
        join -t : -j 1 -o 1.{1..9} - <(getent shadow | sort) |
            awk -F: "!h[\$1]++" - /etc/shadow >/etc/shadow.new &&
        : cp -f /etc/shadow.new /etc/shadow
    '

I strongly recommend that you split the command into pieces to see what it's doing at each stage of the pipeline, and that you DO NOT remove the no-op colon from the : cp on the last line until you are confident that it is working as you expect.

Essentially

  1. Extract a list of usernames from /etc/passwd with UID > 1000
  2. Use this list to extract the corresponding lines from /etc/shadow
  3. Copy across to the remote system
  4. Write out members of the new shadow list that exist in the current /etc/shadow
  5. Write out lines from the old /etc/shadow whose usernames have not already been output
  6. Save the original and new copies of shadow (in known places, for emergency rescue if needed)
  7. Install the resulting merged file as /etc/shadow
  • +1. BTW, instead of using join, awk, sort, and cat on the remote host, you could extract the username:password pairs on the local machine and pipe them to chpasswd -e on the remote host. chpasswd will complain about unknown users (actually, PAM does the complaining), but still changes the usernames that exist. Optionally redirect stderr to /dev/null. – cas Mar 7 '18 at 4:11

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service, privacy policy and cookie policy

Not the answer you're looking for? Browse other questions tagged or ask your own question.