ssh_controls/update_ssh.pl

935 lines
34 KiB
Perl

#!/usr/bin/env perl
#******************************************************************************
# @(#) update_ssh.pl
#******************************************************************************
# @(#) Copyright (C) 2014 by KUDOS BV <info@kudos.be>. All rights reserved.
#
# This program is a free software; you can redistribute it and/or modify
# it under the same terms of the GNU General Public License as published by
# the Free Software Foundation; either version 2 of the License, or
# (at your option) any later version.
#
# This program 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 General Public License for more details
#******************************************************************************
# This script distributes SSH keys to the appropriate files into the designated
# repository based on the 'access', 'alias' and 'keys' configuration files.
# Superfluous usage of 'hostname' reporting in log messages is encouraged to
# make reading of multiplexed output from update_ssh.pl through backgrounded
# jobs via manage_ssh.sh much easier.
#
# @(#) HISTORY: see perldoc 'update_ssh.pl'
# -----------------------------------------------------------------------------
# DO NOT CHANGE THIS FILE UNLESS YOU KNOW WHAT YOU ARE DOING!
#******************************************************************************
#******************************************************************************
# PRAGMAs/LIBs
#******************************************************************************
use strict;
use Net::Domain qw(hostfqdn hostname);
use POSIX qw(uname);
use Data::Dumper;
use Getopt::Long;
use Pod::Usage;
#******************************************************************************
# DATA structures
#******************************************************************************
# ------------------------- CONFIGURATION starts here -------------------------
# define the version (YYYY-MM-DD)
my $script_version = "2025-04-27";
# name of global configuration file (no path, must be located in the script directory)
my $global_config_file = "update_ssh.conf";
# name of localized configuration file (no path, must be located in the script directory)
my $local_config_file = "update_ssh.conf.local";
# maxiumum level of recursion for alias resolution
my $max_recursion = 5;
# selinux context labels of key files for different RHEL version
my %selinux_contexts = ( '5' => 'sshd_key_t',
'6' => 'ssh_home_t',
'7' => 'ssh_home_t',
'8' => 'ssh_home_t',
'9' => 'ssh_home_t',
'10' => 'ssh_home_t');
# disallowed paths for home directories for accounts
my @disallowed_homes = ('/', '/etc', '/bin', '/sbin', '/usr/bin', '/usr/sbin');
# disallowed login shells for @accounts
my @disallowed_shells = ('/bin/nologin','/bin/false','/sbin/nologin','/sbin/false');
# default toggle for key location
my $key_location='use_controls';
# ------------------------- CONFIGURATION ends here ---------------------------
# initialize variables
my ($debug, $verbose, $preview, $remove, $global, $use_fqdn, $ignore_errors) = (0,0,0,0,0,0,0);
my (@config_files, @zombie_files, $access_dir, $blacklist_file);
my (%options, @uname, @pwgetent, @accounts, %aliases, %keys, %access, @blacklist);
my ($os, $hostname, $run_dir, $authorizedkeys_option);
my ($selinux_status, $selinux_context, $linux_version, $has_selinux, $recursion_count) = ("","","",0,1);
$|++;
#******************************************************************************
# SUBroutines
#******************************************************************************
# -----------------------------------------------------------------------------
sub do_log {
my $message = shift;
if ($message =~ /^ERROR:/ || $message =~ /^WARN:/) {
print STDERR "$message\n";
} elsif ($message =~ /^DEBUG:/) {
print STDOUT "$message\n" if ($debug);
} else {
print STDOUT "$message\n" if ($verbose);
}
return (1);
}
# -----------------------------------------------------------------------------
sub parse_config_file {
my $config_file = shift;
unless (open (CONF_FD, "<", $config_file)) {
do_log ("ERROR: failed to open the configuration file ${config_file} [$!/$hostname]")
and exit (1);
}
while (<CONF_FD>) {
chomp ();
# parse settings
if (/^\s*$/ || /^#/) {
next;
} else {
if (/^\s*use_fqdn\s*=\s*(0|1)\s*$/) {
$use_fqdn = $1;
do_log ("DEBUG: picking up setting: use_fqdn=${use_fqdn}");
}
if (/^\s*ignore_errors\s*=\s*(0|1)\s*$/) {
$ignore_errors = $1;
do_log ("DEBUG: picking up setting: ignore_errors=${ignore_errors}");
}
if (/^\s*access_dir\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) {
$access_dir = $1;
do_log ("DEBUG: picking up setting: access_dir=${access_dir}");
}
if (/^\s*key_location\s*=\s*(use_controls|use_sshd)\s*/) {
$key_location = $1;
do_log ("DEBUG: picking up setting: key_location=${key_location}");
if ($key_location eq 'use_sshd') {
do_log ("DEBUG: applied setting: key_location=${key_location}");
}
}
if (/^\s*blacklist_file\s*=\s*([0-9A-Za-z_\-\.\/~]+)\s*$/) {
$blacklist_file = $1;
# support tilde (~) expansion for ~root
$blacklist_file =~ s{ ^ ~ ( [^/]* ) }
{ $1
? (getpwnam($1))[7]
: ( $ENV{HOME} || $ENV{LOGDIR}
|| (getpwuid($>))[7]
)
}ex;
do_log ("DEBUG: picking up setting: blacklist_file=${blacklist_file}");
}
}
}
return (1);
}
# -----------------------------------------------------------------------------
sub resolve_aliases
{
my $input = shift;
my (@tmp_array, @new_array, $entry);
@tmp_array = split (/,/, $input);
foreach $entry (@tmp_array) {
if ($entry =~ /^\@/) {
($aliases{$entry})
? push (@new_array, @{$aliases{$entry}})
: do_log ("WARN: unable to resolve alias $entry [$hostname]");
} else {
($entry)
? push (@new_array, $entry)
: do_log ("WARN: unable to resolve alias $entry [$hostname]");
}
}
return (@new_array);
}
# -----------------------------------------------------------------------------
sub set_file {
my ($file, $perm, $uid, $gid) = @_;
my $rc = chmod ($perm, "$file");
if (!$rc) {
if ($ignore_errors) {
do_log ("ERROR: cannot set permissions on $file [$!/$hostname] -- IGNORED");
} else {
do_log ("ERROR: cannot set permissions on $file [$!/$hostname]");
exit (1);
}
}
my $rc = chown ($uid, $gid, "$file");
if (!$rc) {
if ($ignore_errors) {
do_log ("ERROR: cannot set ownerships on $file [$!/$hostname] -- IGNORED");
} else {
do_log ("ERROR: cannot set ownerships on $file [$!/$hostname]");
exit (1);
}
}
return (1);
}
#******************************************************************************
# MAIN routine
#******************************************************************************
# -----------------------------------------------------------------------------
# process script arguments & options
# -----------------------------------------------------------------------------
if ( @ARGV > 0 ) {
Getopt::Long::Configure ('prefix_pattern=(--|-|\/)', 'bundling', 'no_ignore_case');
GetOptions (\%options,
qw(
debug|d
help|h|?
global|g
ignore|i
preview|p
remove|r
verbose|v
version|V
)) || pod2usage(-verbose => 0);
}
pod2usage(-verbose => 0) unless (%options);
# check version parameter
if ($options{'version'}) {
$verbose = 1;
do_log ("INFO: $0: version $script_version");
exit (0);
}
# check help parameter
if ($options{'help'}) {
pod2usage(-verbose => 3);
exit (0);
};
# check global parameter
if ($options{'global'}) {
$global = 1;
}
# check ignore parameter
if ($options{'ignore'}) {
$ignore_errors = 1;
}
# check preview parameter
if ($options{'preview'}) {
$preview = 1;
$verbose = 1;
if ($global) {
do_log ("INFO: running in GLOBAL PREVIEW mode");
} else {
do_log ("INFO: running in PREVIEW mode");
}
} else {
do_log ("INFO: running in UPDATE mode");
}
# check remove parameter
if ($options{'remove'}) {
$remove = 1 unless ($preview);
}
# debug & verbose
if ($options{'debug'}) {
$debug = 1;
$verbose = 1;
}
$verbose = 1 if ($options{'verbose'});
# -----------------------------------------------------------------------------
# check/process configuration files, environment checks
# -----------------------------------------------------------------------------
# where am I? (1/2)
$0 =~ /^(.+[\\\/])[^\\\/]+[\\\/]*$/;
$run_dir = $1 || ".";
$run_dir =~ s#/$##; # remove trailing slash
# don't do anything without configuration file(s)
do_log ("INFO: parsing configuration file(s) ...");
push (@config_files, "$run_dir/$global_config_file") if (-f "$run_dir/$global_config_file");
push (@config_files, "$run_dir/$local_config_file") if (-f "$run_dir/$local_config_file");
unless (@config_files) {
do_log ("ERROR: unable to find any configuration file, bailing out [$hostname]")
and exit (1);
}
# process configuration file: global first, local may override
foreach my $config_file (@config_files) {
parse_config_file ($config_file);
}
# is the target directory for keys present? (not for global preview and
# not when $key_location is use_sshd)
unless (($preview and $global) or $key_location eq 'use_sshd') {
do_log ("INFO: checking for SSH controls mode ...");
if (-d $access_dir) {
do_log ("INFO: host is under SSH controls via $access_dir");
} else {
if ($key_location eq 'use_sshd') {
do_log ("INFO: skipped check since public key location is determined by sshd [$hostname]")
} else {
do_log ("ERROR: host is not under SSH keys only control [$hostname]")
and exit (1);
}
}
}
# what am I?
@uname = uname();
$os = $uname[0];
# who am I?
unless ($preview and $global) {
if ($< != 0) {
do_log ("ERROR: script must be invoked as user 'root' [$hostname]")
and exit (1);
}
}
# where am I? (2/2)
if ($use_fqdn) {
$hostname = hostfqdn();
} else {
$hostname = hostname();
}
do_log ("INFO: runtime info: ".getpwuid ($<)."; ${hostname}\@${run_dir}; Perl v$]");
# -----------------------------------------------------------------------------
# handle blacklist file
# -----------------------------------------------------------------------------
# do we have a blacklist file? (optional) (not for global preview)
unless ($preview and $global) {
do_log ("INFO: checking for keys blacklist file ...");
if (-f $blacklist_file) {
open (BLACKLIST, "<", $blacklist_file) or \
do_log ("ERROR: cannot read keys blacklist file [$!/$hostname]")
and exit (1);
@blacklist = <BLACKLIST>;
close (BLACKLIST);
do_log ("INFO: keys blacklist file found with ".scalar (@blacklist)." entr(y|ies) on $hostname");
print Dumper (\@blacklist) if $debug;
} else {
do_log ("WARN: no keys blacklist file found [$hostname]");
}
}
# -----------------------------------------------------------------------------
# resolve and check key location
# -----------------------------------------------------------------------------
if ($key_location eq 'use_sshd') {
# get sshd setting but only take 1st path into account
$authorizedkeys_option = qx#sshd -T | grep "authorizedkeysfile" 2>/dev/null | cut -f2 -d' '#;
chomp ($authorizedkeys_option);
if (defined ($authorizedkeys_option)) {
do_log ("INFO: AuthorizedkeysFile resolves to $authorizedkeys_option [$hostname]");
} else {
do_log ("ERROR: unable to get AuthorizedkeysFile value from sshd [$hostname]")
and exit (1);
}
} else {
# for SSH controls native logic we require an absolute path
if ($authorizedkeys_option =~ /^\//) {
do_log ("ERROR: option \$access_dir requires and absolute path [$hostname]")
and exit (1);
}
do_log ("DEBUG: applied default setting: key_location=${key_location}");
}
# -----------------------------------------------------------------------------
# collect user accounts via getpwent()
# result: @accounts
# -----------------------------------------------------------------------------
do_log ("INFO: reading user accounts from pwgetent ...");
while (@pwgetent = getpwent()) {
push (@accounts, $pwgetent[0]);
}
# remove duplicates (which should not happen (!) but local, LDAP and accounts
# from other sources might trample over each other)
my %uniq_accounts = map { $_, 0 } @accounts;
@accounts = keys %uniq_accounts;
do_log ("INFO: ".scalar (@accounts)." user accounts found on $hostname");
print Dumper (\@accounts) if $debug;
# -----------------------------------------------------------------------------
# read aliases for teams, servers and users (and resolve group definitions)
# result: %aliases
# -----------------------------------------------------------------------------
do_log ("INFO: reading 'alias' file ...");
open (ALIASES, "<", "${run_dir}/alias")
or do_log ("ERROR: cannot read 'alias' file [$!/$hostname]") and exit (1);
while (<ALIASES>) {
my ($key, $value, @values);
chomp ();
next if (/^$/ || /\#/);
s/\s+//g;
($key, $value) = split (/:/);
next unless ($value);
@values = sort (split (/\,/, $value));
$aliases{$key} = [@values];
};
close (ALIASES);
do_log ("DEBUG: dumping unexpanded aliases:");
print Dumper (\%aliases) if $debug;
# resolve aliases recursively to a maxium of $max_recursion
while ($recursion_count <= $max_recursion) {
# crawl over all items in the hash %aliases
foreach my $key (keys (%aliases)) {
# crawl over all items in the array @{aliases{$key}}
my @new_array; my @filtered_array; # these are the working stashes
do_log ("DEBUG: expanded alias $key before recursion $recursion_count [$hostname]");
print Dumper (\@{$aliases{$key}}) if $debug;
foreach my $item (@{$aliases{$key}}) {
# is it a group?
if ($item =~ /^\@/) {
# expand the group if it exists
if ($aliases{$item}) {
# add current and new items to the working stash
if (@new_array) {
push (@new_array, @{$aliases{$item}});
} else {
@new_array = (@{$aliases{$key}}, @{$aliases{$item}});
}
# remove the original group item from the working stash
@filtered_array = grep { $_ ne $item } @new_array;
@new_array = @filtered_array;
} else {
do_log ("WARN: unable to resolve alias $item [$hostname]");
}
# no group, just add the item as-is to working stash
} else {
push (@new_array, $item);
}
}
# filter out dupes
my %seen;
@filtered_array = grep { not $seen{$_}++ } @new_array;
# re-assign working stash back to our original hash key
@{$aliases{$key}} = @filtered_array;
do_log ("DEBUG: expanded alias $key after recursion $recursion_count [$hostname]");
print Dumper (\@{$aliases{$key}}) if $debug;
}
$recursion_count++;
}
do_log ("INFO: ".scalar (keys (%aliases))." aliases found on $hostname");
do_log ("DEBUG: dumping expanded aliases:");
print Dumper (\%aliases) if $debug;
# -----------------------------------------------------------------------------
# read SSH keys (incl. the blacklisted keys), supports keys stored in a single
# 'keys.d' file or in individual key files in a 'keys' directory
# result: %keys
# -----------------------------------------------------------------------------
do_log ("INFO: reading 'keys' file(s) ...");
my @key_files;
# check if the SSH keys are stored in a directory or file
if (-d "${run_dir}/keys.d" && -f "${run_dir}/keys") {
do_log ("WARN: found both a 'keys' file and 'keys.d' directory. Ignoring the 'keys' file [$hostname]");
}
if (-d "${run_dir}/keys.d") {
do_log ("INFO: local 'keys' are stored in a DIRECTORY on $hostname");
opendir (KEYS_DIR, "${run_dir}/keys.d")
or do_log ("ERROR: cannot open 'keys.d' directory [$!/$hostname]")
and exit (1);
while (my $key_file = readdir (KEYS_DIR)) {
next if ($key_file =~ /^\./);
push (@key_files, "${run_dir}/keys.d/$key_file");
}
closedir (KEYS_DIR);
} elsif (-f "${run_dir}/keys") {
do_log ("INFO: local 'keys' are stored in a FILE on $hostname");
push (@key_files, "${run_dir}/keys");
} else {
do_log ("ERROR: cannot find any public keys in the repository! [$hostname]")
and exit (1);
}
# process 'keys' files
foreach my $key_file (@key_files) {
open (KEYS, "<", $key_file)
or do_log ("ERROR: cannot read 'keys' file [$!/$hostname]") and exit (1);
do_log ("INFO: reading public keys from file: $key_file");
while (<KEYS>) {
my ($user, $keytype, $key);
chomp ();
next if (/^$/ || /\#/);
# check for blacklisting
my $key_line = $_;
if (grep (/\Q${key_line}\E/, @blacklist)) {
do_log ("WARN: *BLACKLIST*: key match found for '$key_line', ignoring key! [$hostname]");
next;
}
# process key
s/\s+//g;
($user, $keytype, $key) = split (/,/);
next unless ($key);
$keys{$user}{"keytype"} = $keytype;
$keys{$user}{"key"} = $key;
};
close (KEYS);
}
do_log ("INFO: ".scalar (keys (%keys))." public key(s) found on $hostname");
print Dumper(\%keys) if $debug;
# -----------------------------------------------------------------------------
# read access definitions
# result: %access (hash of arrays). The keys are the accounts for which
# access control has been defined for this server. The values are an array
# with all the people who can access the account.
# -----------------------------------------------------------------------------
do_log ("INFO: reading 'access' file ...");
open (ACCESS, "<", "${run_dir}/access")
or do_log ("ERROR: cannot read 'access' file [$!/$hostname]") and exit (1);
while (<ACCESS>) {
my ($who, $where, $what, @who, @where, @what);
chomp ();
next if (/^$/ || /\#/);
s/\s+//g;
($who, $where, $what) = split (/:/);
next unless ($what);
@who = resolve_aliases ($who);
@where = resolve_aliases ($where);
@what = resolve_aliases ($what);
unless (@who and @where and @what) {
do_log ("WARN: ignoring line $. in 'access' due to missing/non-resolving values [$hostname]");
next;
}
foreach my $account (sort (@what)) {
my @new_array;
foreach my $server (sort (@where)) {
foreach my $person (sort (@who)) {
do_log ("DEBUG: adding access for $account to $person on $server in \%access")
if ($server eq $hostname);
# add person to access list if the entry is for this host
push (@new_array, $person) if ($server eq $hostname);
}
}
# add to full access list of persons for this host
push (@{$access{$account}}, @new_array) if (@new_array);
}
};
close (ACCESS);
# remove duplicates in 'persons' in %access{$account}
foreach my $account (keys (%access)) {
@{$access{$account}} = keys (%{{ map { $_ => 1 } @{$access{$account}}}});
}
do_log ("INFO: ".scalar (keys (%access))." accounts with applicable access rules found on $hostname");
print Dumper(\%access) if $debug;
# -----------------------------------------------------------------------------
# global preview, show full configuration data only
# -----------------------------------------------------------------------------
if ($preview && $global) {
do_log ("INFO: display GLOBAL configuration ....");
open (ACCESS, "<", "${run_dir}/access")
or do_log ("ERROR: cannot read 'access' file [$!/$hostname]") and exit (1);
while (<ACCESS>) {
my ($who, $where, $what, @who, @where, @what);
chomp ();
next if (/^$/ || /\#/);
s/\s+//g;
($who, $where, $what) = split (/:/);
next unless ($what);
@who = resolve_aliases ($who);
@where = resolve_aliases ($where);
@what = resolve_aliases ($what);
unless (@who and @where and @what) {
do_log ("WARN: ignoring line $. in 'access' due to missing/non-resolving values [$hostname]");
next;
}
foreach my $account (sort (@what)) {
my @new_array;
foreach my $server (sort (@where)) {
foreach my $person (sort (@who)) {
do_log ("$person|$server|$account")
}
}
}
};
close (ACCESS);
exit (0);
}
# -----------------------------------------------------------------------------
# distribute keys into authorized_keys files
# (defined by $key_location and/or $access_dir)
# -----------------------------------------------------------------------------
do_log ("INFO: applying SSH access rules ....");
# check for SELinux & contexts
unless ($preview) {
SWITCH_OS: {
$os eq "Linux" && do {
# figure out selinux mode
$selinux_status = qx#/usr/sbin/getenforce 2>/dev/null#;
chomp ($selinux_status);
if ($selinux_status eq "Permissive" or $selinux_status eq "Enforcing") {
do_log ("INFO: runtime info: detected active SELinux system on $hostname");
$has_selinux = 1;
}
# figure out RHEL version (via lsb_release or /etc/redhat-release)
$linux_version = qx#/usr/bin/lsb_release -rs 2>/dev/null | /usr/bin/cut -f1 -d'.'#;
chomp ($linux_version);
if (not (defined ($linux_version)) or $linux_version eq "") {
my $release_string;
$release_string = qx#/bin/grep -i "release" /etc/redhat-release 2>/dev/null#;
chomp ($release_string);
SWITCH_RELEASE: {
$release_string =~ m/release 5/i && do {
$linux_version = 5;
last SWITCH_RELEASE;
};
$release_string =~ m/release 6/i && do {
$linux_version = 6;
last SWITCH_RELEASE;
};
$release_string =~ m/release 7/i && do {
$linux_version = 7;
last SWITCH_RELEASE;
};
$release_string =~ m/release 8/i && do {
$linux_version = 8;
last SWITCH_RELEASE;
};
$release_string =~ m/release 9/i && do {
$linux_version = 9;
last SWITCH_RELEASE;
};
$release_string =~ m/release 10/i && do {
$linux_version = 8;
last SWITCH_RELEASE;
};
}
}
# use fall back in case we cannot determine the version
if (not (defined ($linux_version)) or $linux_version eq "") {
$selinux_context = 'etc_t';
$linux_version = 'unknown';
} else {
$selinux_context = $selinux_contexts{$linux_version};
}
if ($has_selinux) {
do_log ("INFO: runtime info: OS major version $linux_version, SELinux context $selinux_context on $hostname");
} else {
do_log ("INFO: runtime info: OS major version $linux_version on $hostname");
}
last SWITCH_OS;
};
}
}
# only add authorized_keys for existing accounts,
# otherwise revoke access if needed
SET_KEY: foreach my $account (sort (@accounts)) {
my ($access_file, $authorizedkeys_file, $uid, $gid, $home_dir, $login_shell) = (undef, undef, undef, undef, undef, undef);
# set $access_file when using SSH controls logic
if ($key_location eq 'use_sshd' and defined ($authorizedkeys_option)) {
# use sshd logic (replacing %u,%h, %%)
$authorizedkeys_file = $authorizedkeys_option;
$authorizedkeys_file =~ s/%u/$account/g;
$authorizedkeys_file =~ s/%h/$hostname/g;
$authorizedkeys_file =~ s/%%/%/g;
# check relative path (assume $HOME needs to be added)
if ($authorizedkeys_file !~ /^\//) {
($uid, $gid, $home_dir, $login_shell) = (getpwnam($account))[2,3,7,8];
# do not accept invalid $HOME or shells
if (defined ($home_dir)) {
if (grep( /^$home_dir$/, @disallowed_homes) or grep( /^$login_shell/, @disallowed_shells)) {
do_log ("DEBUG: invalid HOME or SHELL for $account [$hostname]");
next SET_KEY;
} else {
$authorizedkeys_file = $home_dir."/".$authorizedkeys_file;
do_log ("DEBUG: adding $home_dir to public key path for $account [$hostname]");
}
} else {
do_log ("ERROR: unable to get HOME for $account [$hostname]");
next SET_KEY;
}
}
$access_file = $authorizedkeys_file;
} else {
# use native SSH controls logic
$access_file = "$access_dir/$account";
}
do_log ("DEBUG: public key location for $account resolves to $access_file [$hostname]");
# only add authorised_keys if there are access definitions
if ($access{$account}) {
unless ($preview) {
# do not create root or intermediate paths in $access_file;
# e.g. if $HOME/.ssh/authorized_keys is the public key path, then $HOME/.ssh must already exist
open (KEYFILE, "+>", $access_file)
or do_log ("ERROR: cannot open file for writing at $access_file [$!/$hostname]")
and next SET_KEY;
}
foreach my $person (sort (@{$access{$account}})) {
my $real_name = $person;
$real_name =~ s/([a-z])([A-Z])/$1 $2/g;
# only add authorized_keys if $person actually has a key
if (exists ($keys{$person})) {
# only add authorized_keys if $person actually has an account
print KEYFILE "$keys{$person}{keytype} $keys{$person}{key} $real_name\n"
unless $preview;
do_log ("INFO: granting access to $account for $real_name on $hostname");
} else {
do_log ("INFO: denying access (no key) to $account for $real_name on $hostname");
}
}
close (KEYFILE) unless $preview;
# set ownerships/permissions on public key file and check for SELinux context
unless ($preview) {
if ($key_location eq 'use_controls') {
set_file ($access_file, 0644, 0, 0);
} else {
set_file ($access_file, 0600, $uid, $gid);
}
# selinux labels
SWITCH: {
$os eq "Linux" && do {
if ($has_selinux) {
system ("/usr/bin/chcon -t $selinux_context $access_file") and
do_log ("WARN: failed to set SELinux context $selinux_context on $access_file [$hostname]");
};
last SWITCH;
}
}
}
} else {
# remove obsolete access file if needed (revoking access)
if (-f $access_file) {
unless ($preview) {
unlink ($access_file)
or do_log ("ERROR: cannot remove obsolete access file $access_file [$!/$hostname]")
and exit (1);
} else {
do_log ("INFO: removing obsolete access $access_file on $hostname");
}
}
}
}
# -----------------------------------------------------------------------------
# alert on/remove extraneous authorized_keys files (SSH controls logic only)
# (access files for which no longer a valid UNIX account exists)
# -----------------------------------------------------------------------------
if ($key_location eq 'use_controls') {
do_log ("INFO: checking for extraneous access files ....");
opendir (ACCESS_DIR, $access_dir)
or do_log ("ERROR: cannot open directory $access_dir [$!/$hostname]")
and exit (1);
while (my $access_file = readdir (ACCESS_DIR)) {
next if ($access_file =~ /^\./);
unless (grep (/$access_file/, @accounts)) {
do_log ("WARN: found extraneous access file in $access_dir/$access_file [$hostname]");
push (@zombie_files, "$access_dir/$access_file");
}
}
closedir (ACCESS_DIR);
do_log ("INFO: ".scalar (@zombie_files)." extraneous access file(s) found on $hostname");
print Dumper (\@zombie_files) if $debug;
# remove if requested and needed
if ($remove && @zombie_files) {
my $count = unlink (@zombie_files)
or do_log ("ERROR: cannot remove extraneous access file(s) [$!/$hostname]")
and exit (1);
do_log ("INFO: $count extraneous access files removed $hostname");
}
}
exit (0);
#******************************************************************************
# End of SCRIPT
#******************************************************************************
__END__
#******************************************************************************
# POD
#******************************************************************************
# -----------------------------------------------------------------------------
=head1 NAME
update_ssh.pl - distributes SSH public keys in a desired state model.
=head1 SYNOPSIS
update_ssh.pl[-d|--debug]
[-h|--help]
[-i|--ignore]
([-p|--preview] [-g|--global]) | [-r|--remove]
[-v|--verbose]
[-V|--version]
=head1 DESCRIPTION
B<update_ssh.pl> distributes SSH keys to the appropriate files (.e. 'authorized_keys') into the C<$access_dir> repository based on the F<access>, F<alias> and F<keys> files.
Alternatively B<update_ssh.pl> can distribute public keys to the location specified in the AuthorizedkeysFile setting of F<sshd_config> (allowing public keys to be distributed
to the traditional location in a user's HOME directory). See C<key_location> setting in F<update_ssh.conf[.local]>for more information.
This script should be run on each host where SSH key authentication is the exclusive method of (remote) authentication.
Orginally SSH public keys must be stored in a generic F<keys> file within the same directory as B<update_ssh.pl> script.
Alternatively key files may be stored as set of individual key files within a called sub-directory called F<keys.d>.
Both methods are mutually exclusive and the latter always take precedence.
=head1 CONFIGURATION
B<update_ssh.pl> requires the presence of at least one of the following configuration files:
=over 2
=item * F<update_ssh.conf>
=item * F<update_ssh.conf.local>
=back
Use F<update_ssh.conf.local> for localized settings per host. Settings in the localized configuration file will always override other values.
Following settings must be configured:
=over 2
=item * B<use_fqdn> : whether to use short or FQDN host names
=item * B<ignore_errors> : whether to ignore errors during key deployment
=item * B<access_dir> : target directory for allowed SSH public key files
=item * B<key_location> : whether or not to use AuthorizedkeysFile setting in sshd_config for overriding $access_dir
=item * B<blacklist_file> : location of the file with blacklisted SSH public keys
=back
=head1 BLACKLISTING
Key blacklisting can be performed by adding a public key definition in its entirety to the blacklist keys file. When a blacklisted key is
found in the available F<keys> file(s) during SSH controls updates, an alert will be shown on STDOUT and the key will be ignored for the rest.
Examples:
WARN: *BLACKLIST*: key match found for 'John Doe,ssh-rsa,AAAAB3N'<snip>, ignoring key!
=head1 OPTIONS
=over 2
=item -d | --debug
S< >Be I<very> verbose during execution; show array/hash dumps.
=item -h | --help
S< >Show the help page.
=item -i | --ignore
S< >Ignore errors during key deployment.
=item -p | --preview
S< >Do not actually distribute any SSH public keys, nor update/remove any 'authorized_keys' files.
=item -g | --global
S< >Must be used in conjunction with the --preview option. This will dump the global namespace/configuration to STDOUT.
=item -r | --remove
S< >Remove any extraneous 'authorized_keys' files (i.e. belonging to non-existing accounts in /etc/passwd)
=item -v | --verbose
S< >Be verbose during exection.
=item -V | --version
S< >Show version of the script.
=back
=head1 NOTES
=over 2
=item * Options may be preceded by a - (dash), -- (double dash) or a / (slash).
=item * Options may be bundled (e.g. -vp)
=back
=head1 AUTHOR
(c) KUDOS BV, Patrick Van der Veken