package Sysync;
use strict;
use Digest::MD5 qw(md5_hex);
use File::Find;
use File::Path;
our $VERSION = '0.25';
=head1 NAME
Sysync - Simplistic system management
=head1 SYNOPSIS
See: http://sysync.nongnu.org/tutorial.html
=head1 METHODS
=head3 new
Creates a new Sysync object.
my $sysync = Sysync->new({
sysdir => '/var/sysync',
stagedir => '/var/sysync/stage', # if omitted, appends ./stage to sysdir
salt_prefix => '', # if omitted, defaults to '$6$'
log => $file_handle_for_logging,
});
=cut
sub new
{
my ($class, $params) = @_;
my $self = {
sysdir => $params->{sysdir},
stagedir => ($params->{stagedir} || "$params->{sysdir}/stage"),
stagefilesdir => ($params->{stagefiledir} || "$params->{sysdir}/stage-files"),
salt_prefix => (exists($params->{salt_prefix}) ? $params->{salt_prefix} : '$6$'),
log => $params->{log},
};
bless($self, $class);
return $self;
}
=head3 log
Log a message.
$self->log('the moon is broken');
=cut
sub log
{
my $self = shift;
my $lt = localtime;
my $log = $self->{log};
print $log "$lt: $_[0]\n";
}
=head3 sysdir
Returns the base system directory for sysync.
=cut
sub sysdir { shift->{sysdir} }
=head3 stagedir
Returns stage directory.
=cut
sub stagedir { $_[0]->{stagedir} || join('/', $_[0]->sysdir, 'stage' ) }
=head3 stagefilesdir
Returns stage-files directory.
=cut
sub stagefilesdir { $_[0]->{stagefilesdir} || join('/', $_[0]->sysdir, 'stage-files' ) }
=head3 get_user
Returns hashref of user information. It's worth noting that passwords should not be returned here for normal users.
Example:
{
username => 'wafflewizard',
uid => 1001,
fullname => 'Waffle E. Wizzard',
homedir => '/home/wafflewizard',
shell => '/bin/bash',
disabled => 0,
ssh_keys => [
'ssh-rsa AAAAB3NzaC1yc2EAAAABIwAAAQEA10YAFEAByOlrMmd5Beh73SOg7okHpK5Bz9dOgmYb4idR3A6iz+ycyXtnCmwSGdmh6AQoeKfJx+9rxLtvdHUzhRa/YejqBGsTwYl5Q+1bKbCkJfgZhtB99Xt5j7grXzrJ0zp2vTfG2mPndnD7xuQQQnLsZrFSoTY8FPvQo3a9R1wPIuxBGs5jWm9+pvluJtAT3I7IaVfylNBCGU8+Fw/qvJtWEesyqyRmFJZ47XzFKJ5EzB6hLaW+MAaCH6fZDycdjiTfJOMThtpFF557rqz5EN76VRqHpnkiqKpatMX4h0hiL/Snl+fbUxOYm5qcHughuis4Sf6xXoABsyz2lsrqiQ== wafflewizard',
],
}
=cut
sub get_user { die 'needs implemented' }
=head3 get_all_users
Return array of all usernames.
=cut
sub get_all_users { die 'needs implemented' }
=head3 get_user_password
Return a user's encrypted password.
=cut
sub get_user_password { die 'needs implemented' }
=head3 set_user_password
Set a user's encrypted password.
=cut
sub set_user_password { die 'needs implemented' }
=head3 get_users_from_group
Returns array of users in a given group.
=cut
sub get_users_from_group { die 'needs implemented' }
=head3 get_all_groups
Returns array of all groups.
=cut
sub get_all_groups { die 'needs implemented' }
=head3 get_all_hosts
Returns all hosts.
=cut
sub get_all_hosts { die 'needs implemented' }
=head3 must_refresh
Returns true if sysync must refresh.
Passing 1 or 0 as an argument sets whether this returns true.
=cut
sub must_refresh { die 'needs implemented' }
=head3 must_refresh_files
Returns true if sysync must refresh managed files.
Passing 1 or 0 as an argument sets whether this returns true.
=cut
sub must_refresh_files { die 'needs implemented' }
=head3 generate_user_line
Generate a line for both the user and shadow file.
=cut
sub generate_user_line
{
my ($self, $user, $what) = @_;
my $gid = $user->{gid} || $user->{uid};
my $fullname = $user->{fullname} || $user->{username};
my $password = '*';
if ($user->{password})
{
$password = $user->{password};
}
else
{
my $p = $self->get_user_password($user->{username});
$password = $p if $p;
}
my $line = q[];
if ($what eq 'passwd')
{
$line = join(':', $user->{username}, 'x', $user->{uid}, $gid,
$fullname, $user->{homedir}, $user->{shell});
}
elsif ($what eq 'shadow')
{
my $password = $user->{disabled} ? '!' : $password;
$line = join(':', $user->{username}, $password, 15198, 0, 99999, 7, '','','');
}
return $line;
}
=head3 generate_group_line
Generate a line for the group file.
=cut
sub generate_group_line
{
my ($self, $group) = @_;
my $users = join(',', @{$group->{users} || []}) || '';
return join(':', $group->{groupname}, 'x', $group->{gid}, $users);
}
=head3 is_valid_host
Returns true if host is valid.
=cut
sub is_valid_host { die 'needs implemented' }
=head3 get_host_user
Given a host, then a username, return a hashref with user details.
=cut
sub get_host_user
{
my ($self, $host, $username) = @_;
my $data = $self->get_host_users_groups($host);
my @users = @{$data->{users} || []};
return (grep { $username eq $_->{username} } @users)[0];
}
=head3 get_host_group
Given a host, then a group name, return a hashref with group details.
=cut
sub get_host_group
{
my ($self, $host, $groupname) = @_;
my $data = $self->get_host_users_groups($host);
my @groups = @{$data->{groups} || []};
return (grep { $groupname eq $_->{groupname} } @groups)[0];
}
=head3 get_host_users
Given a host return a hashref with user details.
=cut
sub get_host_users
{
my ($self, $host, $username) = @_;
my $data = $self->get_host_users_groups($host);
my @users = @{$data->{users} || []};
my %u = map { $_->{username} => $_ } @users;
return \%u;
}
=head3 get_host_groups
Given a host return a hashref with group details.
=cut
sub get_host_groups
{
my ($self, $host, $groupname) = @_;
my $data = $self->get_host_users_groups($host);
my @groups = @{$data->{groups} || []};
my %g = map { $_->{groupname} => $_ } @groups;
return \%g;
}
=head3 get_host_ent
For a generate all of the password data, including ssh keys, for a specific host.
=cut
sub get_host_ent
{
my ($self, $host) = @_;
return unless $self->is_valid_host($host);
my $data = $self->get_host_users_groups($host);
my @users = @{$data->{users} || []};
my @groups = @{$data->{groups} || []};
my $passwd = join("\n", map { $self->generate_user_line($_, 'passwd') } @users) . "\n";
my $shadow = join("\n", map { $self->generate_user_line($_, 'shadow') } @users) . "\n";
my $group = join("\n", map { $self->generate_group_line($_) } @groups) . "\n";
my @ssh_keys;
for my $user (@users)
{
next unless $user->{ssh_keys};
my $keys = join("\n", @{$user->{ssh_keys} || []});
$keys .= "\n" if $keys;
next unless $keys;
push @ssh_keys, {
username => $user->{username},
keys => $keys,
uid => $user->{uid},
};
}
return {
passwd => $passwd,
shadow => $shadow,
group => $group,
ssh_keys => \@ssh_keys,
};
}
=head3 get_host_files
Generate a list of files with their content.
Returns hashref:
'/etc/filename.conf' => {
mode => 600,
gid => 0,
uid => 0,
data => 'data is here'
}
=cut
sub get_host_files { die 'needs implemented' }
=head3 update_host_files
Build host files from specifications.
=cut
sub update_host_files
{
my ($self, $host) = @_;
my $stagefilesdir = $self->stagefilesdir;
my $stagedir = $self->stagedir;
my $r = 0;
next unless $self->is_valid_host($host);
my $files = $self->get_host_files($host);
unless (-d "$stagefilesdir/$host")
{
mkdir "$stagefilesdir/$host";
chmod 0755, "$stagefilesdir/$host";
chown 0, 0, "$stagefilesdir/$host";
$self->log("creating: $stagefilesdir/$host");
$r++;
}
unless (-d "$stagefilesdir/$host/etc")
{
mkdir "$stagefilesdir/$host/etc";
chmod 0755, "$stagefilesdir/$host/etc";
chown 0, 0, "$stagefilesdir/$host/etc";
$self->log("creating: $stagefilesdir/$host/etc");
$r++;
}
unless (-d "$stagefilesdir/$host/etc/ssh")
{
mkdir "$stagefilesdir/$host/etc/ssh";
chmod 0755, "$stagefilesdir/$host/etc/ssh";
chown 0, 0, "$stagefilesdir/$host/etc/ssh";
$self->log("creating: $stagefilesdir/$host/etc/ssh");
$r++;
}
unless (-d "$stagefilesdir/$host/etc/ssh/authorized_keys")
{
mkdir "$stagefilesdir/$host/etc/ssh/authorized_keys";
chmod 0755, "$stagefilesdir/$host/etc/ssh/authorized_keys";
chown 0, 0, "$stagefilesdir/$host/etc/ssh/authorized_keys";
$self->log("creating: $stagefilesdir/$host/etc/ssh/authorized_keys");
$r++;
}
for my $path (sort keys %{ $files || {} })
{
my $item = $files->{$path};
next unless $item->{directory};
$item->{directory} =~ s/\/$//;
next if $item->{directory} eq '/etc';
next if $item->{directory} eq '/etc/ssh';
next if $item->{directory} eq '/etc/ssh/authorized_keys';
$item->{directory} =~ s/^\///;
my @path_parts = split('/', $item->{directory});
my $filename = pop @path_parts;
my $parent_dir = join('/', @path_parts);
$item->{file} =~ s/^\///;
unless (-d "$stagefilesdir/$host/$parent_dir")
{
die "[$host: error] parent directory $parent_dir not defined for $item->{directory}\n";
}
unless (-d "$stagefilesdir/$host/$item->{directory}")
{
mkdir "$stagefilesdir/$host/$item->{directory}";
$self->log("creating: $stagefilesdir/$host/$item->{directory}");
}
my $mode = sprintf("%04i", $item->{mode});
chmod $mode, "$stagefilesdir/$host/$item->{directory}";
chown $item->{uid}, $item->{gid}, "$stagefilesdir/$host/$item->{directory}";
$r++;
}
for my $path (keys %{ $files || {} })
{
my $item = $files->{$path};
next unless $item->{file};
my @path_parts = split('/', $item->{file});
my $filename = pop @path_parts;
my $parent_dir = join('/', @path_parts);
$item->{file} =~ s/^\///;
unless (-d "$stagefilesdir/$host/$parent_dir")
{
die "[$host: error] directory $parent_dir not defined for $item->{file}\n";
}
if ($self->write_file_contents("$stagefilesdir/$host/$item->{file}", $item->{data}))
{
$r++;
}
my $mode = sprintf("%04i", $item->{mode});
chmod oct($mode), "$stagefilesdir/$host/$item->{file}";
chown $item->{uid}, $item->{gid}, "$stagefilesdir/$host/$item->{file}";
}
# get list of staging directory contents
my @staged_file_list;
File::Find::find({
wanted => sub { push @staged_file_list, $_ },
no_chdir => 1,
}, "$stagefilesdir/$host");
for my $staged_file (@staged_file_list)
{
next unless -e $staged_file;
(my $local_staged_file = $staged_file) =~ s/^$stagefilesdir\/$host//;
next unless $local_staged_file;
next if $local_staged_file eq '/';
next if $local_staged_file eq '/etc';
next if $local_staged_file eq '/etc/ssh';
next if $local_staged_file eq '/etc/ssh/authorized_keys';
unless ($files->{$local_staged_file})
{
if (-d $staged_file)
{
$self->log("deleting directory: $staged_file");
rmtree($staged_file);
}
elsif (-e $staged_file)
{
$self->log("deleting file: $staged_file");
unlink($staged_file);
}
$r++;
}
}
return $r;
}
=head3 update_all_hosts
Iterate through every host and build password files.
=cut
sub update_all_hosts
{
my ($self, %params) = @_;
# get list of hosts along with image name
my $hosts = $params{hosts} || $self->get_all_hosts;
# first, build staging directories
my @hosts = keys %{ $hosts->{hosts} || {} };
my $stagedir = $self->stagedir;
my $r = 0;
for my $host (@hosts)
{
next unless $self->is_valid_host($host);
unless (-d "$stagedir/$host")
{
mkdir "$stagedir/$host";
chmod 0755, "$stagedir/$host";
chown 0, 0, "$stagedir/$host";
$self->log("creating: $stagedir/$host");
$r++;
}
unless (-d "$stagedir/$host/etc")
{
mkdir "$stagedir/$host/etc";
chmod 0755, "$stagedir/$host/etc";
chown 0, 0, "$stagedir/$host/etc";
$self->log("creating: $stagedir/$host/etc");
$r++;
}
unless (-d "$stagedir/$host/etc/ssh")
{
mkdir "$stagedir/$host/etc/ssh";
chmod 0755, "$stagedir/$host/etc/ssh";
chown 0, 0, "$stagedir/$host/etc/ssh";
$self->log("creating: $stagedir/$host/etc/ssh");
$r++;
}
unless (-d "$stagedir/$host/etc/ssh/authorized_keys")
{
mkdir "$stagedir/$host/etc/ssh/authorized_keys";
chmod 0755, "$stagedir/$host/etc/ssh/authorized_keys";
chown 0, 0, "$stagedir/$host/etc/ssh/authorized_keys";
$self->log("creating: $stagedir/$host/etc/ssh/authorized_keys");
$r++;
}
# write host files
my $ent_data = $self->get_host_ent($host);
next unless $ent_data;
for my $key (@{ $ent_data->{ssh_keys} || [] })
{
my $username = $key->{username};
my $uid = $key->{uid};
my $text = $key->{keys};
if ($self->write_file_contents("$stagedir/$host/etc/ssh/authorized_keys/$username", $text))
{
chmod 0600, "$stagedir/$host/etc/ssh/authorized_keys/$username";
chown $uid, 0, "$stagedir/$host/etc/ssh/authorized_keys/$username";
$r++;
}
}
if ($self->write_file_contents("$stagedir/$host/etc/passwd", $ent_data->{passwd}))
{
chmod 0644, "$stagedir/$host/etc/passwd";
chown 0, 0, "$stagedir/$host/etc/passwd";
$r++;
}
if ($self->write_file_contents("$stagedir/$host/etc/group", $ent_data->{group}))
{
chmod 0644, "$stagedir/$host/etc/group";
chown 0, 0, "$stagedir/$host/etc/group";
$r++;
}
if ($self->write_file_contents("$stagedir/$host/etc/shadow", $ent_data->{shadow}))
{
chmod 0640, "$stagedir/$host/etc/shadow";
chown 0, 42, "$stagedir/$host/etc/shadow";
$r++;
}
}
return $r;
}
=head3 write_file_contents
=cut
sub write_file_contents
{
my ($self, $file, $data) = @_;
# check to see if this differs
if (-e $file)
{
if (md5_hex($data) eq md5_hex($self->read_file_contents($file)))
{
return;
}
}
$self->log("writing: $file");
open(F, "> $file") or die $!;
print F $data;
close(F);
return 1;
}
=head3 read_file_contents
=cut
sub read_file_contents
{
my ($self, $file, %params) = @_;
die "error: $file does not exist\n"
if $params{must_exist} and not -f $file;
return unless -e $file;
open(my $fh, $file);
my @content = <$fh>;
close($fh);
return join('', @content);
}
1;
=head1 COPYRIGHT
2012 Ohio-Pennsylvania Software, LLC.
=head1 LICENSE
Copyright (C) 2012 Ohio-Pennsylvania Software, LLC.
This file is part of Sysync.
Sysync is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as
published by the Free Software Foundation, either version 3 of the
License, or (at your option) any later version.
Sysync is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <http://www.gnu.org/licenses/>.
=head1 AUTHOR
Michael J. Flickinger, C<< <mjflick@gnu.org> >>
=cut