#! /usr/bin/perl -w
#
# @(#)sshlpk-akfgen (2025 edition)
# Requires perl >= version 5.6 (since it uses 'our')
#
# Use this command via the sshd_config file directives:
#   AuthorizedKeysCommand     /usr/local/sbin/sshlpk-akfgen
#   AuthorizedKeysCommandUser root
#   AuthorizedKeysFile        /dev/null
#
# from sshd_config(5):
# AuthorizedKeysCommand accepts the tokens %%, %C, %D, %f, %h, %k, %t, %U, and %u
#
# Note that this would need root privs to read (at least) the local 
# authorized_keys file for root itself (you should not have root in
# LDAP as then there would be no fall-back in case LDAP is not available
# and when combined with an AuthorizedKeysFile directive that disabled
# any local files. 
# (an alternative use would be with `match` blocks for non-LDAP users
#  and set an explicit AuthorizedKeysFile for those users, still disabling
#  user-owned authorized keys for mortals)
#
use strict;

use Net::LDAP;
use Net::LDAP::Util qw(ldap_error_name
                       ldap_error_text); # for error handling
use Getopt::Long qw(:config no_ignore_case bundling);
use Sys::Syslog;
require ConfigTiny and import ConfigTiny unless defined &ConfigTiny::new;

# default are for Nikhef, but can override either with arguments
# (but then use "-u %u" in sshd_config) or the config file
# in /usr/local/etc/

our $verb=0;
our $quiet=0;
our $display_help;
our $configfile="/usr/local/etc/sshlpk-akfgen.conf";
our $ldapurl="ldaps://ldap.nikhef.nl/";
our $ldapbase="dc=farmnet,dc=nikhef,dc=nl";
our $ldapbinddn="";
our $ldapbindpw="";
our $ldapbindpwfile="";
our $ldapbindsssd_config="";
our $ldapbindsssd_domain="domain/default";
our $ldapfilter='(objectClass=ldapPublicKey)';
our $ldapuidattr='uid';
our $localaccounts="(root)";
our @localaccountslist=();
our $localkeysfile='%h/.ssh/authorized_keys';
our $logfacility='authpriv';
our $prefix="";
our $uid=undef;

my $ppid=getppid; # parent pid usually identified the sshd instance
( my $progname=$0 ) =~ s/.*\///;

sub configfile_handler {
    my ($opt_name, $opt_value) = @_;
    die "Internal error: invalid option $opt_name assigned to configfile_handler\n"
        unless $opt_name eq "configfile" or $opt_name eq "";

    if ( defined $configfile and -r $opt_value ) {
        print "# parsing configuration $opt_value\n" if $verb > 1;
        open CFG,"<$configfile" or die "Cannot open config $configfile: $!\n";
        my $config = do { local $/; <CFG> };
        close CFG;
        $SIG{'__WARN__'} = sub { }; eval($config); $SIG{'__WARN__'} = 'DEFAULT';
        die "Invalid statement in config $configfile: $@\n" if $@;
    }
    # clear the default config file since we already read one
    $configfile=undef;
}

sub display_help {
    print <<EOF;
Usage: $progname [-v] [-configfile perlish-config] [-H ldap-url] [-b baseDN]
  [-f filter] [--akfile AKFile-pattern] [--sssd-config|S file] [--sssd-domain]
  [--binddn|D DN] [--bindpwfile|P file] [-L non-ldap-accounts-regex] 
  ([--filter_user uid|_NSS)*] [-F syslogfacility] [-u uid] [uid]

  -C cfgfile    Config file in perl syntax
  -H ldapurl    LDAP URL (default: $ldapurl)
                set to '_NSS' to use the first value from sssd domain
  -b basedn     LDAP DIT Base DN (default: $ldapbase) or '_NSS'
  --akfile pat  AuthorizedKeysFile-pattern (only tokens %h and %u recognised)
                default: $localkeysfile
  -u uid        username to search for (takes precedence over ARGV[0])

Advanced options
  --sssd-config|S <file>
        Read LDAP bindDN and password (plaintext) from sssd config <file>
        SSSD parsing is only used when option is set (in config or argument)
  --sssd-domain <domain>
        Within the sssd config file, read data from domain <domain>
  --filter_user <uid>
        add <uid> also to the list of users that use local (non-LDAP) lookup
        like the "-L" localaccounts regex. The token '_NSS' is expended 
        to the filter_users list in the 'nss' section of sssd.conf if an 
        sssd configuration file is provided (which typically includes 
        'root,ldap'). Typical usage '-L _NSS'
  -localaccounts-rexeg regex
        accounts that will not be LDAP looked-up (anchored regex,
        default: '$localaccounts')
  -D binddn
        bind to directory as <binddn> (set to _NSS to read from sssd.conf)
  -y bindpwfile
        read the bind password from the file <bindpwfile>
        (it can also be set in config file with '\$ldapbindpw=""'
         but then protect the config file!)
  --prefix|P options-string
        prepend each line in the output with '<options-string><SP>' followed
        by the ssh key. The following tokens are expanded:
            \@UID\@         uid of the user as specified in the arguments
            %u, %U, %h, %l  uid, uidnumber, homedirectory, loginshell
        Typical use:
            -P 'command=\"/usr/bin/yes bye \@UID\@\",no-port-forwarding'

EOF
}

&GetOptions(
  'verbose|v+' => \$verb,
  'help|h' => \&display_help,
  'configfile|C=s' => \&configfile_handler,
  'url|H=s' => \$ldapurl,
  'base|b=s' => \$ldapbase,
  'binddn|D=s' => \$ldapbinddn,
  'bindpwfile|y=s' => \$ldapbindpwfile,
  'sssd-config|S=s' => \$ldapbindsssd_config,
  'sssd-domain=s' => \$ldapbindsssd_domain,
  'akfile|A=s' => \$localkeysfile,
  'localaccounts-rexeg=s' => \$localaccounts,
  'filter_user|L=s@' => \@localaccountslist,
  'syslogfacility=s' => \$logfacility,
  'filter=s' => \$ldapfilter,
  'prefix|P=s' => \$prefix,
  'uid|u=s' => \$uid,
);

if ( defined $configfile ) { &configfile_handler("",$configfile); }

die "Too many arguments\n" if ( $#ARGV > 0 );
if ( ! defined $uid and $#ARGV < 0 ) {
    # we need at least one uid somewhere to get the ssh keys
    die "No username provided\n";
} elsif ( ! defined $uid ) {
    $uid=$ARGV[0];
}
$uid =~ /^[a-zA-Z0-9]+$/ or die "Invalid username given\n";

# determine necessity for reading sssd config (which can be used
# for binding credentials and for localaccounts)
if ( $ldapbindsssd_config and $ldapbindsssd_domain ) {
    if ( ! -r $ldapbindsssd_config ) {
        syslog("err","cannot read $ldapbindsssd_config: no output");
        die "Cannot read $ldapbindsssd_config\n";
    }
    my $sssd = ConfigTiny->new();
    if ( ! $sssd->read($ldapbindsssd_config) ) {
        syslog("err","invalid sssd config file $ldapbindsssd_config: no output");
        die "Invalid sssd config syntax in $ldapbindsssd_config\n";
    }
    if ( grep '_NSS',@localaccountslist ) {
        @localaccountslist = grep !'@NSS@',@localaccountslist;
        @localaccountslist = grep !'_NSS',@localaccountslist;
        if ( defined $sssd->{'nss'} and defined $sssd->{'nss'}->{'filter_users'} ) {
            push @localaccountslist,(split/,/,$sssd->{'nss'}->{'filter_users'});
        }
        print "# non-ldap users: @localaccountslist\n" if $verb > 2 ;
    }
    if ( $ldapurl eq '_NSS' and
            defined $sssd->{$ldapbindsssd_domain} and
            defined $sssd->{$ldapbindsssd_domain}->{'ldap_uri'} ) {
        $ldapurl = (split ',',$sssd->{$ldapbindsssd_domain}->{'ldap_uri'})[0];
        print "# set LDAP URL from sssd to $ldapurl\n" if $verb > 0;
    }
    if ( $ldapbase eq '_NSS' and
            defined $sssd->{$ldapbindsssd_domain} and
            defined $sssd->{$ldapbindsssd_domain}->{'ldap_search_base'} ) {
        $ldapbase = $sssd->{$ldapbindsssd_domain}->{'ldap_search_base'};
        print "# set LDAP BaseDN from sssd to $ldapbase\n" if $verb > 0;
    }
    if ( $ldapbinddn eq '_NSS' and
            defined $sssd->{$ldapbindsssd_domain} and
            defined $sssd->{$ldapbindsssd_domain}->{'ldap_default_bind_dn'} ) {
        $ldapbinddn = $sssd->{$ldapbindsssd_domain}->{'ldap_default_bind_dn'};
        print "# set LDAP bindDN from sssd to $ldapbinddn\n" if $verb > 0;
    }
    if ( $ldapbindpw eq '_NSS' and
            defined $sssd->{$ldapbindsssd_domain} and
            defined $sssd->{$ldapbindsssd_domain}->{'ldap_default_authtok'} ) {
        $ldapbindpw = $sssd->{$ldapbindsssd_domain}->{'ldap_default_authtok'};
        print "# set LDAP bind password from sssd\n" if $verb > 2;
    }
}

# from here use syslog as primary reporting mechanism
# since output is used to construct the AuthorizedKeysFile equivalent
openlog($progname,"nofatal,pid",$logfacility);

# accounts (regex) that are exempt from LDAP but use local AK-files
# if a fall-back file pattern is specified. Also the list of filter_users is
# exempt from LDAP lookup.
# Note: on RHEL<=7 there is a maximum length (~10 keys) due to a bug in sshd
#
if ( $localkeysfile and ( 
        $uid =~ /^$localaccounts$/ or
        grep /$uid/,@localaccountslist
    ) ) { 
    my $homedir=(getpwnam $uid)[7]; # user should be local if no LDAP set
    my $pat = $localkeysfile;
    $pat =~ s/%h/$homedir/g;
    $pat =~ s/(%u|\@UID\@)/$uid/g;
    print "# reading file $pat\n";
    if ( -r $pat ) { 
        syslog("info","auth of $uid for $ppid: using file $pat");
        my $fh;
        if ( open $fh, '<', $pat ) {
            my $akf_content = do { local $/; <$fh> };
            close $fh;
            syslog("info","$pat: ".length($akf_content)." characters");
            print $akf_content;
        } else {
            syslog("err","opening $pat failed: $!");
        }
        exit 0; 
    }
    syslog("err","auth of $uid for $ppid: no authorizedKeysFile $pat");
    exit 0;
}

# connect to LDAP
my $ldap = Net::LDAP->new( $ldapurl, timeout=>10 );
if ( ! defined $ldap or ! $ldap ) {
    syslog("err","cannot connect to $ldapurl for $uid: no output");
    die "Cannot contact remote server at $ldapurl: $!\n";
};

if ( $ldapbinddn ) {
    if ( !$ldapbindpw and -r $ldapbindpwfile ) { 
        # obtain password from protected file
        if ( open my $pwfilehandle,'<',$ldapbindpwfile ) {
            $ldapbindpw=<$pwfilehandle>;
            close $pwfilehandle;
            chomp($ldapbindpw);
            if ( ! $ldapbindpw ) { 
                syslog("err","LDAP bind password from $ldapbindpwfile is empty");
                die "LDAP bind password from $ldapbindpwfile is empty\n"
            } elsif ( length($ldapbindpw) < 8 )  {
                syslog("warn","LDAP bind password from $ldapbindpwfile is rather short");
            }
        } else {
            syslog("err","Cannot open LDAP bind password file $ldapbindpwfile: $!");
            die "Cannot open LDAP bind password file $ldapbindpwfile: $!\n";
        }
    }
    # attempt binding, even with an empty password (who knows!)
    #
    my $ldap_status = $ldap->bind($ldapbinddn, 'password' => $ldapbindpw);
    if ( $ldap_status->code ) {
        syslog("err","Cannot bind to LDAP $ldapurl as $ldapbinddn: ".$ldap_status->error);
        die "Cannot bind to LDAP $ldapurl as $ldapbinddn: ".$ldap_status->error."\n";
    }
}

my $sresult = $ldap->search(
        base => "$ldapbase",
        scope => 'sub',
        filter => "(&($ldapuidattr=$uid)$ldapfilter)",
        attrs => [ 'uid', 'sshPublicKey', 'homeDirectory', 'loginShell', 'uidNumber' ]
    );
if ( $sresult->code ) {
    syslog("err","Search for uid $uid on $ldapurl failed: ".$sresult->error);
    die "Search for uid $uid on $ldapurl failed: ".$sresult->error."\n";
}

# proceed to list keys for ALL entries where ((uid=%u)(filter)) matches
my $nentries = $sresult->count();
my @entries = $sresult->entries;
print "# found $nentries entries for uid $uid\n" if $verb > 0;

# complete token expansion in options to ssh key 
$prefix=~s/\@UID\@/$uid/g;

foreach my $entry ( @entries ) {
  my $dn = $entry->dn;
  print "# dn: $dn\n";
  my $i=0;
  my @value_homedirectory = $entry->get_value('homeDirectory');
  my @value_loginshell = $entry->get_value('loginShell');
  my @value_uidnumber = $entry->get_value('uidNumber');
  my @value_uid = $entry->get_value('uid');

  my $entry_uid = (@value_uid?$value_uid[0]:"");
  my $entry_uidnumber = (@value_uidnumber?$value_uidnumber[0]:"");
  my $entry_loginshell = (@value_loginshell?$value_loginshell[0]:"");
  my $entry_homedirectory = (@value_homedirectory?$value_homedirectory[0]:"");

  my @sshPublicKey = $entry->get_value('sshPublicKey');
  foreach my $s ( @sshPublicKey ) {
    # expand per-entry tokens based on LDAP data
    my $p = $prefix;
    $p =~ s/%u/$entry_uid/g;
    $p =~ s/%U/$entry_uidnumber/g;
    $p =~ s/%l/$entry_loginshell/g;
    $p =~ s/%h/$entry_homedirectory/g;
    $p and print "$p ";
    print "$s\n";
    $i++;
  }
  syslog("info","auth of $uid for $ppid: using $i sshPublicKey values from $dn");
}

# ###########################################################################
# ConfigTiny is imported here in-line since it is an unusual dependency
# but we may need it to parse sssd.conf
#
package ConfigTiny;

# derived from Config::Tiny 2.12, but with some local mods and
# some new syntax possibilities

# If you thought Config::Simple was small...

use strict;
BEGIN {
        require 5.004;
        $ConfigTiny::VERSION = '2.12';
        $ConfigTiny::errstr  = '';
}

# Create an empty object
sub new { bless {}, shift }

# Create an object from a file
sub read {
        my $class = ref $_[0] ? shift : ref shift;

        # Check the file
        my $file = shift or return $class->_error( 'You did not specify a file name' );
        return $class->_error( "File '$file' does not exist" )              unless -e $file;
        return $class->_error( "'$file' is a directory, not a file" )       unless -f _;
        return $class->_error( "Insufficient permissions to read '$file'" ) unless -r _;

        # Slurp in the file
        local $/ = undef;
        open CFG, $file or return $class->_error( "Failed to open file '$file': $!" );
        my $contents = <CFG>;
        close CFG;

        return $class->read_string( $contents );
}

# Create an object from a string
sub read_string {
        my $class = ref $_[0] ? shift : ref shift;
        my $self  = $class;
        #my $self  = bless {}, $class;
        #my $self  = shift;
        return undef unless defined $_[0];

        # Parse the file
        my $ns      = '_';
        my $counter = 0;
        my $content = shift;
        $content =~ s/\\(?:\015{1,2}\012|\015|\012)\s*//gm;
        foreach ( split /(?:\015{1,2}\012|\015|\012)/, $content ) {
                $counter++;

                # Skip comments and empty lines
                next if /^\s*(?:\#|\;|$)/;

                # Remove inline comments
                s/\s\;\s.+$//g;

                # Handle section headers
                if ( /^\s*\[\s*(.+?)\s*\]\s*$/ ) {
                        # Create the sub-hash if it doesn't exist.
                        # Without this sections without keys will not
                        # appear at all in the completed struct.
                        $self->{$ns = $1} ||= {};
                        next;
                }

                # Handle properties
                if ( /^\s*([^=]+?)\s*=\s*(.*?)\s*$/ ) {
                        $self->{$ns}->{$1} = $2;
                        next;
                }

                # Handle settings
                if ( /^\s*([^=]+?)\s*$/ ) {
                        $self->{$ns}->{$1} = 1;
                        next;
                }

                return $self->_error( "Syntax error at line $counter: '$_'" );
        }

        return $self;
}

# Save an object to a file
sub write {
        my $self = shift;
        my $file = shift or return $self->_error(
                'No file name provided'
                );

        # Write it to the file
        open( CFG, '>' . $file ) or return $self->_error(
                "Failed to open file '$file' for writing: $!"
                );
        print CFG $self->write_string;
        close CFG;
}

# Save an object to a string
sub write_string {
        my $self = shift;

        my $contents = '';
        foreach my $section ( sort { (($b eq '_') <=> ($a eq '_')) || ($a cmp $b) } keys %$self ) {
                my $block = $self->{$section};
                $contents .= "\n" if length $contents;
                $contents .= "[$section]\n" unless $section eq '_';
                foreach my $property ( sort keys %$block ) {
                        $contents .= "$property=$block->{$property}\n";
                }
        }

        $contents;
}

# Error handling
sub errstr { $ConfigTiny::errstr }
sub _error { $ConfigTiny::errstr = $_[1]; undef }

1;

