run_edit: convert to using path_to_dn() run_copy & run_move: convert to using path_to_dn() run_grep: convert to using path_to_dn() run_passwd: convert to using path_to_dn() FossilOrigin-Name: fc6d77c7619cabcd8f79bc3f7e41fd2627e246ee011858eb23af9d5dfe827970
1845 lines
40 KiB
Perl
Executable file
1845 lines
40 KiB
Perl
Executable file
#!/usr/bin/env perl
|
|
# vim: set nosta noet ts=4 sw=4:
|
|
#
|
|
# Copyright (c) 2006-2011, Mahlon E. Smith <mahlon@martini.nu>
|
|
# All rights reserved.
|
|
# Redistribution and use in source and binary forms, with or without
|
|
# modification, are permitted provided that the following conditions are met:
|
|
#
|
|
# * Redistributions of source code must retain the above copyright
|
|
# notice, this list of conditions and the following disclaimer.
|
|
#
|
|
# * Redistributions in binary form must reproduce the above copyright
|
|
# notice, this list of conditions and the following disclaimer in the
|
|
# documentation and/or other materials provided with the distribution.
|
|
#
|
|
# * Neither the name of Mahlon E. Smith nor the names of his
|
|
# contributors may be used to endorse or promote products derived
|
|
# from this software without specific prior written permission.
|
|
#
|
|
# THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND ANY
|
|
# EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
|
|
# WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
|
# DISCLAIMED. IN NO EVENT SHALL THE REGENTS AND CONTRIBUTORS BE LIABLE FOR ANY
|
|
# DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
|
|
# (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
|
|
# LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
|
|
# ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
|
|
# (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
|
|
# SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
|
|
|
=head1 NAME
|
|
|
|
Shelldap - A program for interacting with an LDAP server via a shell-like interface
|
|
|
|
=head1 DESCRIPTION
|
|
|
|
Shelldap /LDAP::Shell is a program for interacting with an LDAP server via a shell-like
|
|
interface.
|
|
|
|
This is not meant to be an exhaustive LDAP editing and browsing
|
|
interface, but rather an intuitive shell for performing basic LDAP
|
|
tasks quickly and with minimal effort.
|
|
|
|
=head1 SYNPOSIS
|
|
|
|
shelldap --server example.net [--help]
|
|
|
|
=head1 FEATURES
|
|
|
|
- Upon successful authenticated binding, credential information is
|
|
auto-cached to ~/.shelldap.rc -- future loads require no command line
|
|
flags.
|
|
|
|
- Custom 'description maps' for entry listings. (See the 'list' command.)
|
|
|
|
- History and autocomplete via readline, if installed.
|
|
|
|
- Automatic reconnection attempts if the connection is lost with the
|
|
LDAP server.
|
|
|
|
- It feels like a semi-crippled shell, making LDAP browsing and editing
|
|
at least halfway pleasurable.
|
|
|
|
=head1 OPTIONS
|
|
|
|
All command line options follow getopts long conventions.
|
|
|
|
shelldap --server example.net --basedn dc=your,o=company
|
|
|
|
You may also optionally create a ~/.shelldap.rc file with command line
|
|
defaults. This file should be valid YAML. (This file is generated
|
|
automatically on a successful bind auth.)
|
|
|
|
Example:
|
|
|
|
server: ldap.example.net
|
|
binddn: cn=Manager,dc=your,o=company
|
|
bindpass: xxxxxxxxx
|
|
basedn: dc=your,o=company
|
|
tls: yes
|
|
tls_cacert: /etc/ssl/certs/cacert.pem
|
|
tls_cert: ~/.ssl/client.cert.pem
|
|
tls_key: ~/.ssl/private/client.key.pem
|
|
|
|
=over 4
|
|
|
|
=item B<server>
|
|
|
|
Required. The LDAP server to connect to. This can be a hostname, IP
|
|
address, or a URI.
|
|
|
|
--server ldaps://ldap.example.net
|
|
-H ldaps://ldap.example.net
|
|
|
|
=back
|
|
|
|
=over 4
|
|
|
|
=item B<binddn>
|
|
|
|
The full dn of a user to authenticate as. If not specified, defaults to
|
|
an anonymous bind. You will be prompted for a password.
|
|
|
|
--binddn cn=Manager,dc=your,o=company
|
|
-D cn=Manager,dc=your,o=company
|
|
|
|
=back
|
|
|
|
=over 4
|
|
|
|
=item B<basedn>
|
|
|
|
The directory 'root' of your LDAP server. If omitted, shelldap will
|
|
try and ask the server for a sane default.
|
|
|
|
--basedn dc=your,o=company
|
|
-b dc=your,o=company
|
|
|
|
=back
|
|
|
|
=over 4
|
|
|
|
=item B< tls>
|
|
|
|
Enables TLS over what would normally be an insecure connection.
|
|
Requires server side support.
|
|
|
|
=item B<tls_cacert>
|
|
|
|
Specify CA Certificate to trust.
|
|
|
|
--tls_cacert /etc/ssl/certs/cacert.pem
|
|
|
|
=item B<tls_cert>
|
|
|
|
The TLS client certificate.
|
|
|
|
--tls_cert ~/.ssl/client.cert.pem
|
|
|
|
=item B<tls_key>
|
|
|
|
The TLS client key. Not specifying a key will connect via TLS without
|
|
key verification.
|
|
|
|
--tls_key ~/.ssl/private/client.key.pem
|
|
|
|
=back
|
|
|
|
=over 4
|
|
|
|
=item B<cacheage>
|
|
|
|
Set the time to cache directory lookups in seconds.
|
|
|
|
By default, directory lookups are cached for 300 seconds, to speed
|
|
autocomplete up when changing between different basedns.
|
|
|
|
Modifications to the directory automatically reset the cache. Directory
|
|
listings are not cached. (This is just used for autocomplete.) Set it
|
|
to 0 to disable caching completely.
|
|
|
|
=back
|
|
|
|
=over 4
|
|
|
|
=item B<timeout>
|
|
|
|
Set the maximum time an LDAP operation can take before it is cancelled.
|
|
|
|
=back
|
|
|
|
=over 4
|
|
|
|
=item B<debug>
|
|
|
|
Print extra operational info out, and backtrace on fatal error.
|
|
|
|
=back
|
|
|
|
=head1 SHELL COMMANDS
|
|
|
|
=over 4
|
|
|
|
=item B< cat>
|
|
|
|
Display an LDIF dump of an entry. Globbing is supported. Specify
|
|
either the full dn, or an rdn. For most commands, rdns are local to the
|
|
current search base. ('cwd', as translated to shell speak.) You may additionally
|
|
add a list of attributes to display. Use '+' for server side attributes.
|
|
|
|
cat uid=mahlon
|
|
cat ou=*
|
|
cat uid=mahlon,ou=People,dc=example,o=company
|
|
cat uid=mahlon + userPassword
|
|
|
|
=item B< cd>
|
|
|
|
Change DN. Translated to LDAP, this changes the current basedn.
|
|
All commands after a 'cd' operate within the new basedn.
|
|
|
|
cd change to 'home' basedn
|
|
cd ~ same thing
|
|
cd - change to previous node
|
|
cd ou=People change to explicit path below current node
|
|
cd .. change to parent node
|
|
cd ..,..,ou=Groups change to node ou=Groups, which is a sibling
|
|
to the current node's parent node
|
|
|
|
Since LDAP doesn't actually limit what can be a container object, you
|
|
can actually cd into any entry. Many commands then work on '.', meaning
|
|
"wherever I currently am."
|
|
|
|
cd uid=mahlon
|
|
cat .
|
|
|
|
=item B<clear>
|
|
|
|
Clear the screen.
|
|
|
|
=item B<copy>
|
|
|
|
Copy an entry to a different dn path. All copies are relative to the
|
|
current basedn, unless a full dn is specified. All attributes are
|
|
copied, then an LDAP moddn() is performed.
|
|
|
|
copy uid=mahlon uid=bob
|
|
copy uid=mahlon ou=Others,dc=example,o=company
|
|
copy uid=mahlon,ou=People,dc=example,o=company uid=mahlon,ou=Others,dc=example,o=company
|
|
|
|
aliased to: cp
|
|
|
|
=item B<create>
|
|
|
|
Create an entry from scratch. Arguments are space separated objectClass
|
|
names. Possible objectClasses are derived automatically from the
|
|
server, and will tab-complete.
|
|
|
|
After the classes are specified, an editor will launch. Required
|
|
attributes are listed first, then optional attributes. Optionals are
|
|
commented out. After the editor exits, the resulting LDIF is validated
|
|
and added to the LDAP directory.
|
|
|
|
create top person organizationalPerson inetOrgPerson posixAccount
|
|
|
|
aliased to: touch
|
|
|
|
=item B<delete>
|
|
|
|
Remove an entry from the directory. Globbing is supported.
|
|
All deletes are sanity-prompted.
|
|
|
|
delete uid=mahlon
|
|
delete uid=ma*
|
|
|
|
aliased to: rm
|
|
|
|
=item B<edit>
|
|
|
|
Edit an entry in an external editor. After the editor exits, the
|
|
resulting LDIF is sanity checked, and changes are written to the LDAP
|
|
directory.
|
|
|
|
edit uid=mahlon
|
|
|
|
aliased to: vi
|
|
|
|
=item B< env>
|
|
|
|
Show values for various runtime variables.
|
|
|
|
=item B<grep>
|
|
|
|
Search for arbitrary LDAP filters, and return matching dn results.
|
|
The search string must be a valid LDAP filter.
|
|
|
|
grep uid=mahlon
|
|
grep uid=mahlon ou=People
|
|
grep -r (&(uid=mahlon)(objectClass=*))
|
|
|
|
aliased to: search
|
|
|
|
=item B<list>
|
|
|
|
List entries for the current basedn. Globbing is supported.
|
|
|
|
aliased to: ls
|
|
|
|
ls -l
|
|
ls -lR uid=mahlon
|
|
list uid=m*
|
|
list verbose
|
|
|
|
In 'verbose' mode, descriptions are listed as well, if they exist.
|
|
There are also some 'sane' long listings for common objectClass types.
|
|
You can actually specify your own in your .shelldap.rc, like so:
|
|
|
|
...
|
|
descmaps:
|
|
objectClass: attributename
|
|
posixAccount: gecos
|
|
posixGroup: gidNumber
|
|
ipHost: ipHostNumber
|
|
puppetClient: puppetclass
|
|
|
|
=item B<mkdir>
|
|
|
|
Creates a new entry. The type of object created depends on
|
|
the naming attribute given, and defaults to 'organizationalUnit'
|
|
if none is given.
|
|
Supported naming attributes and corresponding object classes are:
|
|
c - country
|
|
o - organization
|
|
ou - organizationalUnit
|
|
|
|
mkdir myNewOrgUnit
|
|
mkdir o=myNewOrg
|
|
|
|
=item B<move>
|
|
|
|
Move an entry to a different dn path. Usage is identical to B<copy>.
|
|
|
|
aliased to: mv
|
|
|
|
=item B<passwd>
|
|
|
|
If supported server side, change the password for a specified entry.
|
|
The entry must have a 'userPassword' attribute.
|
|
|
|
passwd uid=mahlon
|
|
|
|
=item B< pwd>
|
|
|
|
Print the 'working directory' - aka, the current ldap basedn.
|
|
|
|
=item B<setenv>
|
|
|
|
Modify various runtime variables normally set from the command line.
|
|
|
|
setenv debug 1
|
|
export debug=1
|
|
|
|
=item B<whoami>
|
|
|
|
Show current auth credentials. Unless you specified a binddn, this
|
|
will just show an anonymous bind.
|
|
|
|
=back
|
|
|
|
=head1 TODO
|
|
|
|
Referral support. Currently, if you try to write to a replicant slave,
|
|
you'll just get a referral. It would be nice if shelldap automatically
|
|
tried to follow it.
|
|
|
|
For now, it only makes sense to connect to a master if you plan on doing
|
|
any writes.
|
|
|
|
=head1 BUGS / LIMITATIONS
|
|
|
|
There is no support for editing binary data. If you need to edit base64
|
|
stuff, just feed it to the regular ldapmodify/ldapadd/etc tools.
|
|
|
|
=head1 AUTHOR
|
|
|
|
Mahlon E. Smith <mahlon@martini.nu>
|
|
|
|
=cut
|
|
|
|
package LDAP::Shell;
|
|
use strict;
|
|
use warnings;
|
|
use Term::ReadKey;
|
|
use Term::Shell;
|
|
use Digest::MD5;
|
|
use Net::LDAP qw(LDAP_SUCCESS LDAP_SERVER_DOWN);
|
|
use Net::LDAP::Util qw(canonical_dn ldap_explode_dn);
|
|
use Net::LDAP::LDIF;
|
|
use Data::Dumper;
|
|
use File::Temp;
|
|
use Algorithm::Diff;
|
|
use Carp 'confess';
|
|
use base 'Term::Shell';
|
|
require Net::LDAP::Extension::SetPassword;
|
|
|
|
my $conf = $main::conf;
|
|
|
|
# make 'die' backtrace in debug mode
|
|
$SIG{'__DIE__'} = \&Carp::confess if $conf->{'debug'};
|
|
|
|
###############################################################
|
|
#
|
|
# UTILITY FUNCTIONS
|
|
#
|
|
###############################################################
|
|
|
|
# initial shell behaviors
|
|
#
|
|
sub init
|
|
{
|
|
my $self = shift;
|
|
$self->{'API'}->{'match_uniq'} = 0;
|
|
|
|
$self->{'editor'} = $ENV{'EDITOR'} || 'vi';
|
|
$self->{'env'} = [ qw/ debug cacheage timeout / ];
|
|
|
|
# let autocomplete work with the '=' character
|
|
my $term = $self->term();
|
|
$term->Attribs->{'basic_word_break_characters'} =~ s/=//m;
|
|
$term->Attribs->{'completer_word_break_characters'} =~ s/=//m;
|
|
|
|
# read in history
|
|
eval {
|
|
$term->history_truncate_file("$ENV{'HOME'}/.shelldap_history", 50);
|
|
$term->ReadHistory("$ENV{'HOME'}/.shelldap_history");
|
|
};
|
|
|
|
$self->{'root_dse'} = $self->ldap->root_dse();
|
|
if ( $conf->{'debug'} ) {
|
|
$self->{'schema'} = $self->ldap->schema();
|
|
my @versions = $self->{'root_dse'}->get_value('supportedLDAPVersion');
|
|
print "Connected to $conf->{'server'}\n";
|
|
print "Supported LDAP version: ", ( join ', ', @versions ), "\n";
|
|
print "Cipher in use: ", $self->ldap()->cipher(), "\n";
|
|
}
|
|
|
|
# try an initial search and die if it doesn't work
|
|
# (bad baseDN)
|
|
my $s = $self->search();
|
|
die "LDAP baseDN error: ", $s->{'message'}, "\n" if $s->{'code'};
|
|
|
|
$self->{'schema'} = $self->ldap->schema();
|
|
|
|
# okay, now do an initial population of 'cwd'
|
|
# for autocomplete.
|
|
$self->update_entries();
|
|
|
|
# whew, okay. Update prompt, wait for input!
|
|
$self->update_prompt();
|
|
|
|
return;
|
|
}
|
|
|
|
|
|
# get an ldap connection handle
|
|
#
|
|
sub ldap
|
|
{
|
|
my $self = shift;
|
|
|
|
# use cached connection object if it exists
|
|
return $self->{'ldap'} if $self->{'ldap'};
|
|
|
|
# fill in potentially missing info
|
|
die "No server specified.\n" unless $conf->{'server'};
|
|
|
|
# Emit a nicer error message if IO::Socket::SSL is
|
|
# not installed and Net::LDAP decides it is required.
|
|
#
|
|
if ( $conf->{'tls'} || $conf->{'server'} =~ m|ldaps://| ) {
|
|
eval 'use IO::Socket::SSL';
|
|
die qq{IO::Socket::SSL not installed, but is required for SSL or TLS connections.
|
|
You may try connecting insecurely, or install the module and try again.\n} if $@;
|
|
}
|
|
|
|
if ( $conf->{'binddn'} && ! $conf->{'bindpass'} ) {
|
|
print "Bind password: ";
|
|
Term::ReadKey::ReadMode 2;
|
|
chomp($conf->{'bindpass'} = <STDIN>);
|
|
Term::ReadKey::ReadMode 0;
|
|
print "\n";
|
|
}
|
|
|
|
# make connection
|
|
my $ldap = Net::LDAP->new( $conf->{'server'} )
|
|
or die "Unable to connect to LDAP server '$conf->{'server'}': $!\n";
|
|
|
|
# secure connection options
|
|
#
|
|
if ( $conf->{'tls'} ) {
|
|
if ( $conf->{'tls_key'} ) {
|
|
$ldap->start_tls(
|
|
verify => 'require',
|
|
cafile => $conf->{'tls_cacert'},
|
|
clientcert => $conf->{'tls_cert'},
|
|
clientkey => $conf->{'tls_key'},
|
|
keydecrypt => sub {
|
|
print "Key Passphrase: ";
|
|
Term::ReadKey::ReadMode 2;
|
|
chomp(my $secret = <STDIN>);
|
|
Term::ReadKey::ReadMode 0;
|
|
print "\n";
|
|
return $secret;
|
|
});
|
|
}
|
|
else {
|
|
$ldap->start_tls( verify => 'none' );
|
|
}
|
|
}
|
|
|
|
# bind
|
|
my $rv;
|
|
if ( $conf->{'binddn'} ) {
|
|
# authed
|
|
$rv = $ldap->bind(
|
|
$conf->{'binddn'},
|
|
password => $conf->{'bindpass'}
|
|
);
|
|
}
|
|
else {
|
|
# anon
|
|
$rv = $ldap->bind();
|
|
}
|
|
|
|
my $err = $rv->error();
|
|
if ( $rv->code() ) {
|
|
$err .= " (try the --tls flag?)"
|
|
if $err =~ /confidentiality required/i;
|
|
die "LDAP bind error: $err\n";
|
|
}
|
|
|
|
# offer to cache authentication info
|
|
# if we enter this conditional, we have successfully
|
|
# authed with the server (non anonymous), and
|
|
# we haven't cached anything in the past.
|
|
#
|
|
if ( $conf->{'binddn'} && ! -e $conf->{'confpath'} ) {
|
|
print "Would you like to cache your connection information? [Y/n]: ";
|
|
chomp( my $response = <STDIN> );
|
|
unless ( $response =~ /^n/i ) {
|
|
YAML::Syck::DumpFile( $conf->{'confpath'}, $conf );
|
|
chmod 0600, $conf->{'confpath'};
|
|
print "Connection info cached.\n";
|
|
}
|
|
}
|
|
|
|
$self->{'ldap'} = $ldap;
|
|
return $ldap;
|
|
}
|
|
|
|
# just return an LDIF object
|
|
#
|
|
sub ldif
|
|
{
|
|
my $self = shift;
|
|
my $use_temp = shift;
|
|
|
|
# create tmpfile and link ldif object with it
|
|
if ( $use_temp ) {
|
|
my ( undef, $fname ) =
|
|
File::Temp::tempfile( 'shelldap_XXXXXXXX', DIR => '/tmp', UNLINK => 1 );
|
|
$self->{'ldif'} = Net::LDAP::LDIF->new( $fname, 'w', sort => 1 );
|
|
$self->{'ldif_fname'} = $fname;
|
|
}
|
|
|
|
# ldif -> stdout
|
|
else {
|
|
$self->{'ldif'} = Net::LDAP::LDIF->new( \*STDOUT, 'w', sort => 1 );
|
|
}
|
|
|
|
return $self->{'ldif'};
|
|
}
|
|
|
|
# load and return an Entry object from LDIF
|
|
#
|
|
sub load_ldif
|
|
{
|
|
my $self = shift;
|
|
|
|
my $ldif = Net::LDAP::LDIF->new( shift(), 'r' );
|
|
return unless $ldif;
|
|
|
|
my $e;
|
|
eval { $e = $ldif->read_entry(); };
|
|
|
|
return if $@;
|
|
return $e;
|
|
}
|
|
|
|
# given a filename, return an md5 checksum
|
|
#
|
|
sub chksum
|
|
{
|
|
my $self = shift;
|
|
my $file = shift or return;
|
|
|
|
my $md5 = Digest::MD5->new();
|
|
open F, $file or die "Unable to read temporary ldif: $!\n";
|
|
my $hash = $md5->addfile( *F )->hexdigest();
|
|
close F;
|
|
|
|
return $hash;
|
|
}
|
|
|
|
# prompt functions
|
|
#
|
|
sub prompt_str
|
|
{
|
|
my $self = shift;
|
|
return $self->{'prompt'};
|
|
}
|
|
sub update_prompt
|
|
{
|
|
my $self = shift;
|
|
my $base = $self->base();
|
|
|
|
if ( length $base > 50 ) {
|
|
my $cwd_dn = $1 if $base =~ /^(.*?),/;
|
|
$self->{'prompt'} = "... $cwd_dn > ";
|
|
}
|
|
else {
|
|
my $prompt = $base;
|
|
$prompt =~ s/$conf->{'basedn'}/~/;
|
|
$self->{'prompt'} = "$prompt > ";
|
|
}
|
|
return;
|
|
}
|
|
|
|
# search base accessor
|
|
#
|
|
sub base
|
|
{
|
|
my $self = shift;
|
|
$self->{'base'} ||= $conf->{'basedn'};
|
|
|
|
# try and determine base automatically from rootDSE
|
|
#
|
|
unless ( $self->{'base'} ) {
|
|
my @namingContexts = $self->{'root_dse'}->get_value('namingContexts');
|
|
$conf->{'basedn'} = $namingContexts[0];
|
|
$self->{'base'} = $namingContexts[0];
|
|
}
|
|
if ( $_[0] ) {
|
|
my $base = canonical_dn( $_[0], casefold => 'none' );
|
|
$self->{'base'} = $base if $base;
|
|
}
|
|
return $self->{'base'};
|
|
}
|
|
|
|
# make sure a given rdn includes the current
|
|
# base, making it a dn.
|
|
# accepts a string reference.
|
|
#
|
|
sub rdn_to_dn
|
|
{
|
|
my $self = shift;
|
|
my $rdn = shift or return;
|
|
|
|
return unless ref $rdn;
|
|
|
|
# allow cd to 'basedn' and cd to directories 'higher' in the tree
|
|
return if $$rdn =~ /$conf->{'basedn'}$/;
|
|
|
|
# auto fill in current base for deeper DNs
|
|
my ( $dn, $curbase ) = ( $$rdn, $self->base() );
|
|
$dn = "$$rdn," . $curbase unless $$rdn =~ /$curbase/i;
|
|
|
|
$$rdn = $dn;
|
|
}
|
|
|
|
# do a search on a dn to determine if it is valid.
|
|
# returns a bool.
|
|
#
|
|
sub is_valid_dn
|
|
{
|
|
my $self = shift;
|
|
my $dn = shift or return 0;
|
|
|
|
my $r = $self->search({ base => $dn });
|
|
|
|
return $r->{'code'} == LDAP_SUCCESS ? 1 : 0;
|
|
}
|
|
|
|
# perform an ldap search
|
|
# return an hashref containing return code and
|
|
# arrayref of Net::LDAP::Entry objects
|
|
#
|
|
sub search
|
|
{
|
|
my $self = shift;
|
|
my $opts = shift || {};
|
|
|
|
$opts->{'base'} ||= $self->base(),
|
|
$opts->{'filter'} ||= '(objectClass=*)';
|
|
$opts->{'scope'} ||= 'base';
|
|
|
|
my $s = $self->ldap->search(
|
|
base => $opts->{'base'},
|
|
filter => $opts->{'filter'},
|
|
scope => $opts->{'scope'},
|
|
timelimit => $conf->{'timeout'},
|
|
typesonly => ! $opts->{'vals'},
|
|
attrs => $opts->{'attrs'} || ['*']
|
|
);
|
|
|
|
my $rv = {
|
|
code => $s->code(),
|
|
message => $s->error(),
|
|
entries => []
|
|
};
|
|
|
|
# since search is used just about everywhere, this seems like
|
|
# a pretty good place to check for connection errors.
|
|
#
|
|
# check for a lost connection, kill cached object so we
|
|
# try to reconnect on the next search.
|
|
#
|
|
$self->{'ldap'} = undef if $s->code() == LDAP_SERVER_DOWN;
|
|
|
|
$rv->{'entries'} =
|
|
$opts->{'scope'} eq 'base' ? [ $s->shift_entry() ] : [ $s->entries() ];
|
|
|
|
return $rv;
|
|
}
|
|
|
|
# update the autocomplete for entries
|
|
# in the current base tree, respecting or creating cache.
|
|
#
|
|
sub update_entries
|
|
{
|
|
my $self = shift;
|
|
my %opts = @_;
|
|
my $base = lc( $self->base() );
|
|
|
|
my $s = $opts{'search'} || $self->search({ scope => 'one' });
|
|
|
|
$self->{'cwd_entries'} = [];
|
|
return if $s->{'code'};
|
|
|
|
# setup cache object
|
|
$self->{'cache'} ||= {};
|
|
$self->{'cache'}->{ $base } ||= {};
|
|
$self->{'cache'}->{ $base } = {} if $opts{'clearcache'};
|
|
my $cache = $self->{'cache'}->{ $base };
|
|
|
|
my $now = time();
|
|
if ( ! exists $cache->{'entries'}
|
|
or $now - $cache->{'timestamp'} > $conf->{'cacheage'} )
|
|
{
|
|
$self->debug("Caching entries for $base\n");
|
|
foreach my $e ( @{ $s->{'entries'} } ) {
|
|
my $dn = $e->dn();
|
|
my $rdn = $dn;
|
|
$rdn =~ s/,$base//i; # remove base from display
|
|
push @{ $self->{'cwd_entries'} }, $rdn;
|
|
}
|
|
$cache->{'timestamp'} = $now;
|
|
$cache->{'entries'} = $self->{'cwd_entries'};
|
|
}
|
|
else {
|
|
$self->debug("Using cached lookups for $base\n");
|
|
}
|
|
|
|
$self->{'cwd_entries'} = $cache->{'entries'};
|
|
return;
|
|
}
|
|
|
|
# convert a given path to a DN: deal with '..', '.', '~'
|
|
# Synopsis: $dn = $self->path_to_dn($path, [relative => N]);
|
|
sub path_to_dn
|
|
{
|
|
my $self = shift;
|
|
my $path = shift;
|
|
my %flags = @_;
|
|
my $base = $self->base();
|
|
|
|
# fail on wrong / missing parameter
|
|
return undef if (!defined($path));
|
|
|
|
# return configured base DN
|
|
return($conf->{basedn}) if ($path eq '~');
|
|
|
|
# return current base DN
|
|
return($base) if ($path eq '.');
|
|
|
|
if ($path =~ /^\.\./o) { # relative path
|
|
# support '..' (possibly iterated and as prefix to a DN)
|
|
my @base = @{ldap_explode_dn($base, casefold => 'none')};
|
|
|
|
# deal with leading ..,
|
|
while ($path =~ /^\.\./) {
|
|
shift(@base) if (@base);
|
|
$path =~ s/^\.\.//;
|
|
last if ($path !~ /,\s*/);
|
|
$path =~ s/,\s*//;
|
|
}
|
|
|
|
# build a new absolute DN
|
|
$path .= ',' . canonical_dn(\@base, casefold => 'none')
|
|
if (@base);
|
|
}
|
|
elsif ($path =~ /,\s*~$/o) { # absolute path
|
|
$path =~ s/,\s*~$//;
|
|
$path.= ','.$conf->{basedn}
|
|
if ($conf->{basedn});
|
|
}
|
|
else { # relative or absolute path
|
|
$path.= ','.$conf->{basedn}
|
|
if ($conf->{basedn} && $flags{relative});
|
|
}
|
|
|
|
return($path);
|
|
}
|
|
|
|
# given an array ref of shell-like globs,
|
|
# make and return an LDAP filter object.
|
|
#
|
|
sub make_filter
|
|
{
|
|
my $self = shift;
|
|
my $globs = shift or return;
|
|
|
|
return unless ref $globs eq 'ARRAY';
|
|
return unless scalar @$globs;
|
|
|
|
my $filter;
|
|
$filter = join('', map { (/^\(.*\)$/o) ? $_ : "($_)" } @$globs);
|
|
$filter = '(|' . $filter . ')' if (scalar(@$globs) > 1);
|
|
$filter = Net::LDAP::Filter->new($filter);
|
|
|
|
if ( $filter ) {
|
|
$self->debug('Filter parsed as: ' . $filter->as_string() . "\n");
|
|
}
|
|
else {
|
|
print "Error parsing filter.\n";
|
|
return;
|
|
}
|
|
|
|
return $filter;
|
|
}
|
|
|
|
|
|
# check whether a given string may be a filter
|
|
# Synopsis: $yesNo = $self->is_valid_filter($string);
|
|
sub is_valid_filter
|
|
{
|
|
my $self = shift;
|
|
my $filter = shift or return;
|
|
my $filterObject = Net::LDAP::Filter->new($filter);
|
|
|
|
return $filterObject ? 1 : 0
|
|
}
|
|
|
|
# little. yellow. different. better.
|
|
#
|
|
sub debug
|
|
{
|
|
my $self = shift;
|
|
return unless $conf->{'debug'};
|
|
print "\e[33m";
|
|
print shift();
|
|
print "\e[0m";
|
|
return;
|
|
}
|
|
|
|
# setup command autocompletes for
|
|
# all commands that have the same possible values
|
|
#
|
|
sub autocomplete_cwd
|
|
{
|
|
my $self = shift;
|
|
my $word = $_[0];
|
|
|
|
return sort @{ $self->{'cwd_entries'} };
|
|
}
|
|
|
|
sub comp_setenv
|
|
{
|
|
my $self = shift;
|
|
return @{ $self->{'env'} };
|
|
}
|
|
|
|
sub comp_create
|
|
{
|
|
my $self = shift;
|
|
return @{ $self->{'objectclasses'} } if $self->{'objectclasses'};
|
|
|
|
my @oc_data = $self->{'schema'}->all_objectclasses();
|
|
my @oc;
|
|
foreach my $o ( @oc_data ) {
|
|
push @oc, $o->{'name'};
|
|
}
|
|
@oc = sort @oc;
|
|
$self->{'objectclasses'} = \@oc;
|
|
|
|
return @oc;
|
|
}
|
|
|
|
{
|
|
no warnings;
|
|
no strict 'refs';
|
|
|
|
# command, alias
|
|
my %cmd_map = (
|
|
whoami => 'id',
|
|
list => 'ls',
|
|
grep => 'search',
|
|
edit => 'vi',
|
|
delete => 'rm',
|
|
copy => 'cp',
|
|
cat => 'read',
|
|
move => 'mv',
|
|
cd => undef,
|
|
passwd => undef
|
|
);
|
|
|
|
# setup autocompletes
|
|
foreach ( %cmd_map ) {
|
|
next unless $_;
|
|
my $sub = "comp_$_";
|
|
*$sub = \&autocomplete_cwd;
|
|
}
|
|
*comp_touch = \&comp_create;
|
|
*comp_export = \&comp_setenv;
|
|
|
|
# setup alias subs
|
|
#
|
|
# Term::Shell has an alias_* feature, but
|
|
# it seems to work about 90% of the time.
|
|
# that last 10% is something of a mystery.
|
|
#
|
|
$cmd_map{'create'} = 'touch';
|
|
foreach my $cmd ( keys %cmd_map ) {
|
|
next unless defined $cmd_map{$cmd};
|
|
my $alias_sub = 'run_' . $cmd_map{$cmd};
|
|
my $real_sub = 'run_' . $cmd;
|
|
*$alias_sub = \&$real_sub;
|
|
}
|
|
}
|
|
|
|
|
|
# Given an $arrayref, remove LDIF continuation wrapping,
|
|
# effectively making each entry a single line.
|
|
#
|
|
sub unwrap {
|
|
my $array = shift;
|
|
|
|
my $i = 1;
|
|
while ( $i < scalar(@$array) ) {
|
|
if ( $array->[$i] =~ /^\s/ ) {
|
|
$array->[ $i - 1 ] =~ s/\n$//;
|
|
$array->[ $i ] =~ s/^\s//;
|
|
splice( @$array, $i - 1, 2, $array->[$i - 1] . $array->[$i] );
|
|
}
|
|
else {
|
|
$i++;
|
|
}
|
|
}
|
|
}
|
|
|
|
|
|
###############################################################
|
|
#
|
|
# SHELL METHODS
|
|
#
|
|
###############################################################
|
|
|
|
# don't die on a newline
|
|
#
|
|
sub run_ { return; }
|
|
|
|
# print shell debug actions
|
|
#
|
|
sub precmd
|
|
{
|
|
my $self = shift;
|
|
my ( $handler, $cmd, $args ) = @_;
|
|
|
|
my $term = $self->term();
|
|
eval { $term->WriteHistory("$ENV{'HOME'}/.shelldap_history"); };
|
|
|
|
return unless $conf->{'debug'};
|
|
$self->debug( "$$cmd (" . ( join ' ', @$args ) . "), calling '$$handler'\n" );
|
|
return;
|
|
}
|
|
|
|
sub run_cat
|
|
{
|
|
my $self = shift;
|
|
my $dn = shift;
|
|
my @attrs = (@_) ? @_ : ('*');
|
|
|
|
unless ( $dn ) {
|
|
print "No dn provided.\n";
|
|
return;
|
|
}
|
|
|
|
# support '.'
|
|
$dn = $self->base() if $dn eq '.';
|
|
|
|
# support globbing
|
|
my $s;
|
|
if ( $dn eq '*' ) {
|
|
$s = $self->search({
|
|
scope => 'one',
|
|
vals => 1,
|
|
attrs => \@attrs
|
|
});
|
|
}
|
|
elsif ( $dn =~ /\*/ ) {
|
|
$s = $self->search({
|
|
scope => 'one',
|
|
vals => 1,
|
|
filter => $dn,
|
|
attrs => \@attrs
|
|
});
|
|
}
|
|
else {
|
|
# convert given path to DN
|
|
$dn = $self->path_to_dn($dn, relative => 1);
|
|
$s = $self->search({
|
|
base => $dn,
|
|
vals => 1,
|
|
attrs => \@attrs
|
|
});
|
|
}
|
|
|
|
if ( $s->{'code'} ) {
|
|
print $s->{'message'} . "\n";
|
|
return;
|
|
}
|
|
|
|
foreach my $e ( @{ $s->{'entries'} } ) {
|
|
$self->ldif->write_entry( $e );
|
|
print "\n";
|
|
}
|
|
return;
|
|
}
|
|
|
|
sub run_cd
|
|
{
|
|
my $self = shift;
|
|
my $newbase = join ' ', @_;
|
|
|
|
# support 'cd' going to root
|
|
$newbase ||= $conf->{'basedn'};
|
|
|
|
# support 'cd -'
|
|
if ( $newbase eq '-' ) {
|
|
$newbase = $self->{'previous_base'} || return;
|
|
}
|
|
|
|
# convert given path to a DN
|
|
$newbase = $self->path_to_dn($newbase, relative => 1);
|
|
|
|
unless ( $self->is_valid_dn( $newbase ) ) {
|
|
print "No such object\n";
|
|
return;
|
|
}
|
|
|
|
# store old base
|
|
$self->{'previous_base'} = $self->base();
|
|
|
|
# update new base
|
|
$self->base( $newbase );
|
|
|
|
# get new 'cwd' listing
|
|
my $s = $self->search({ scope => 'one', attrs => [ '1.1' ] });
|
|
if ( $s->{'code'} ) {
|
|
print "$s->{'message'}\n";
|
|
return;
|
|
}
|
|
$self->update_entries( search => $s );
|
|
|
|
# reflect cwd change in prompt
|
|
$self->update_prompt();
|
|
return;
|
|
}
|
|
|
|
sub run_clear
|
|
{
|
|
my $self = shift;
|
|
system('clear');
|
|
return;
|
|
}
|
|
|
|
sub run_copy
|
|
{
|
|
my $self = shift;
|
|
my ( $s_dn, $d_dn ) = @_;
|
|
|
|
unless ( $s_dn ) {
|
|
print "No source dn provided.\n";
|
|
return;
|
|
}
|
|
unless ( $d_dn ) {
|
|
print "No destination dn provided.\n";
|
|
return;
|
|
}
|
|
|
|
# convert given source path to DN
|
|
$s_dn = $self->path_to_dn($s_dn, relative => 1);
|
|
|
|
my $s = $self->search({ base => $s_dn, vals => 1 });
|
|
unless ( $s->{'code'} == LDAP_SUCCESS ) {
|
|
print "No such object\n";
|
|
return;
|
|
}
|
|
|
|
# see if we're copying the entry to a totally new path
|
|
my ( $new_dn, $old_dn );
|
|
( $d_dn, $new_dn ) = ( $1, $2 ) if $d_dn =~ /^([\w=]+),(.*)$/;
|
|
if ( $new_dn ) {
|
|
unless ( $self->is_valid_dn( $new_dn ) ) {
|
|
print "Invalid destination.\n";
|
|
return;
|
|
}
|
|
}
|
|
else {
|
|
$new_dn = $self->base();
|
|
}
|
|
$old_dn = $1 if $s_dn =~ /^[\w=]+,(.*)$/;
|
|
|
|
# get the source object
|
|
my $e = ${ $s->{'entries'} }[0];
|
|
$e->dn( $s_dn );
|
|
|
|
# add changes in new entry instead of modifying existing
|
|
$e->changetype('add');
|
|
$e->dn( "$d_dn,$new_dn" );
|
|
|
|
# get the unique attribute from the dn for modification
|
|
# perhaps there is a better way to do this...?
|
|
#
|
|
my ( $uniqkey, $uniqval ) = ( $1, $2 )
|
|
if $d_dn =~ /^([\.\w\-]+)(?:\s+)?=(?:\s+)?([\.\-\s\w]+),?/;
|
|
unless ( $uniqkey && $uniqval ) {
|
|
print "Unable to parse unique values from rdn.\n";
|
|
return;
|
|
}
|
|
$e->replace( $uniqkey => $uniqval );
|
|
|
|
# update
|
|
my $rv = $e->update( $self->ldap() );
|
|
print $rv->error , "\n";
|
|
|
|
# clear caches
|
|
$self->{'cache'}->{ $new_dn } = {} if $new_dn;
|
|
$self->{'cache'}->{ $old_dn } = {} if $old_dn;
|
|
$self->update_entries( clearcache => 1 );
|
|
return;
|
|
}
|
|
|
|
sub run_create
|
|
{
|
|
my $self = shift;
|
|
my @ocs = @_;
|
|
|
|
my ( $fh, $fname ) =
|
|
File::Temp::tempfile( 'shelldap_XXXXXXXX', DIR => '/tmp', UNLINK => 1 );
|
|
|
|
# first print out the dn and object classes.
|
|
print $fh 'dn: ???,', $self->base(), "\n";
|
|
foreach my $oc ( sort @ocs ) {
|
|
print $fh "objectClass: $oc\n";
|
|
}
|
|
|
|
# now gather attributes for requested objectClasses
|
|
#
|
|
my ( %seen, @must_attr, @may_attr );
|
|
foreach my $oc ( sort @ocs ) {
|
|
|
|
# required
|
|
my @must = $self->{'schema'}->must( $oc );
|
|
foreach my $attr ( sort { $a->{'name'} cmp $b->{'name'} } @must ) {
|
|
next if $attr->{'name'} =~ /^objectclass$/i;
|
|
next if $seen{ $attr->{'name'} };
|
|
push @must_attr, $attr->{'name'};
|
|
$seen{ $attr->{'name'} }++;
|
|
}
|
|
|
|
# optional
|
|
my @may = $self->{'schema'}->may( $oc );
|
|
foreach my $attr ( sort { $a->{'name'} cmp $b->{'name'} } @may ) {
|
|
next if $attr->{'name'} =~ /^objectclass$/i;
|
|
next if $seen{ $attr->{'name'} };
|
|
push @may_attr, $attr->{'name'};
|
|
$seen{ $attr->{'name'} }++;
|
|
}
|
|
}
|
|
|
|
# print attributes
|
|
print $fh "$_: \n" foreach @must_attr;
|
|
print $fh "# $_: \n" foreach @may_attr;
|
|
close $fh;
|
|
my $hash_a = $self->chksum( $fname );
|
|
system( $self->{'editor'}, $fname ) && die "Unable to launch editor: $!\n";
|
|
|
|
# hash compare
|
|
my $hash_b = $self->chksum( $fname );
|
|
if ( $hash_a eq $hash_b ) {
|
|
print "Entry not modified.\n";
|
|
unlink $fname;
|
|
return;
|
|
}
|
|
|
|
# load in LDIF
|
|
my $ldif = Net::LDAP::LDIF->new( $fname, 'r', onerror => 'warn' );
|
|
my $e = $ldif->read_entry();
|
|
unless ( $e ) {
|
|
print "Unable to parse LDIF.\n";
|
|
unlink $fname;
|
|
return;
|
|
}
|
|
$e->changetype('add');
|
|
my $rv = $e->update( $self->ldap() );
|
|
print $rv->error(), "\n";
|
|
|
|
$self->update_entries( clearcache => 1 ) unless $rv->code();
|
|
|
|
unlink $fname;
|
|
return;
|
|
}
|
|
|
|
sub run_delete
|
|
{
|
|
my $self = shift;
|
|
my @DNs = @_;
|
|
|
|
unless ( scalar @DNs ) {
|
|
print "No dn specified.\n";
|
|
return;
|
|
}
|
|
|
|
my $filter;
|
|
unless ( $DNs[0] eq '*' ) {
|
|
$filter = $self->make_filter( \@DNs ) or return;
|
|
}
|
|
|
|
my $s = $self->search({ scope => 'one', filter => $filter });
|
|
if ( $s->{'code'} ) {
|
|
print "$s->{'message'}\n";
|
|
return;
|
|
}
|
|
|
|
print "Are you sure? [N/y]: ";
|
|
chomp( my $resp = <STDIN> );
|
|
return unless $resp =~ /^y/i;
|
|
|
|
foreach my $e ( @{ $s->{'entries'} } ) {
|
|
my $dn = $e->dn();
|
|
my $rv = $self->ldap->delete( $dn );
|
|
print "$dn: ", $rv->error(), "\n";
|
|
}
|
|
|
|
$self->update_entries( clearcache => 1 );
|
|
return;
|
|
}
|
|
|
|
sub run_edit
|
|
{
|
|
my $self = shift;
|
|
my $dn = join ' ', @_;
|
|
|
|
unless ( $dn ) {
|
|
print "No dn provided.\n";
|
|
return;
|
|
}
|
|
|
|
# convert given path to DN
|
|
$dn = $self->path_to_dn($dn, relative => 1);
|
|
|
|
my $s = $self->search({ base => $dn, vals => 1 });
|
|
|
|
if ( $s->{'code'} ) {
|
|
print $s->{'message'} . "\n";
|
|
return;
|
|
}
|
|
|
|
# fetch entry and write it out to disk
|
|
my $e = ${ $s->{'entries'} }[0];
|
|
my $ldif = $self->ldif(1);
|
|
$ldif->write_entry( $e );
|
|
$ldif->done(); # force sync
|
|
|
|
# load it into an array for potential comparison
|
|
open LDIF, "$self->{'ldif_fname'}" or return;
|
|
my @orig_ldif = <LDIF>;
|
|
close LDIF;
|
|
|
|
# checksum it, then open it in an editor
|
|
my $hash_a = $self->chksum( $self->{'ldif_fname'} );
|
|
system( "$self->{'editor'} $self->{'ldif_fname'}" ) &&
|
|
die "Unable to launch editor: $!\n";
|
|
|
|
# detect a total lack of change
|
|
my $hash_b = $self->chksum( $self->{'ldif_fname'} );
|
|
if ( $hash_a eq $hash_b ) {
|
|
print "Entry not modified.\n";
|
|
unlink $self->{'ldif_fname'};
|
|
return;
|
|
}
|
|
|
|
# check changes for basic LDIF validity
|
|
my $new_e = $self->load_ldif( $self->{'ldif_fname'} );
|
|
unless ( $new_e ) {
|
|
print "Unable to parse LDIF.\n";
|
|
unlink $self->{'ldif_fname'};
|
|
return;
|
|
}
|
|
|
|
# load changes into a new array for comparison
|
|
open LDIF, "$self->{'ldif_fname'}" or return;
|
|
my @new_ldif = <LDIF>;
|
|
close LDIF;
|
|
|
|
$e->changetype('modify');
|
|
|
|
my $parse = sub {
|
|
my $line = shift || $_;
|
|
return if $line =~ /^\#/; # ignore comments
|
|
my ( $attr, $val ) = ( $1, $2 ) if $line =~ /^(.+?): (.*)$/;
|
|
return unless $attr;
|
|
return if index($attr, ':') != -1; # ignore base64
|
|
return ( $attr, $val );
|
|
};
|
|
|
|
unwrap( \@orig_ldif );
|
|
unwrap( \@new_ldif );
|
|
|
|
my $diff = Algorithm::Diff->new( \@orig_ldif, \@new_ldif );
|
|
HUNK:
|
|
while ( $diff->Next() ) {
|
|
next if $diff->Same();
|
|
my $diff_bit = $diff->Diff();
|
|
my %seen_attr;
|
|
|
|
# total deletions
|
|
if ( $diff_bit == 1 ) {
|
|
foreach ( $diff->Items(1) ) {
|
|
$self->debug("DELETE: $_");
|
|
my ( $attr, $val ) = $parse->( $_ ) or next;
|
|
$e->delete( $attr => [ $val ] );
|
|
}
|
|
}
|
|
|
|
# new insertions
|
|
if ( $diff_bit == 2 ) {
|
|
foreach ( $diff->Items(2) ) {
|
|
$self->debug("INSERT: $_");
|
|
my ( $attr, $val ) = $parse->( $_ ) or next;
|
|
$e->add( $attr => $val );
|
|
}
|
|
}
|
|
|
|
# replacements
|
|
if ( $diff_bit == 3 ) {
|
|
foreach ( $diff->Items(2) ) {
|
|
$self->debug("MODIFY: $_");
|
|
my ( $attr, $val ) = $parse->( $_ ) or next;
|
|
|
|
my $cur_vals = $e->get_value( $attr, asref => 1 ) || [];
|
|
my $cur_valcount = scalar @$cur_vals;
|
|
next if $cur_valcount == 0; # should have been an 'add'
|
|
|
|
# replace immediately
|
|
#
|
|
if ( $cur_valcount == 1 ) {
|
|
$e->replace( $attr => $val );
|
|
}
|
|
else {
|
|
|
|
# make sure the replace doesn't squash
|
|
# other attributes listed with the same name
|
|
#
|
|
next if $seen_attr{ $attr };
|
|
my @new_vals;
|
|
foreach my $line ( @new_ldif ) {
|
|
my ( $new_attr, $new_val ) = $parse->( $line ) or next;
|
|
next unless $new_attr eq $attr;
|
|
$seen_attr{ $attr }++;
|
|
push @new_vals, $new_val;
|
|
}
|
|
$e->replace( $attr => \@new_vals );
|
|
}
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
unlink $self->{'ldif_fname'};
|
|
my $rv = $e->update( $self->ldap );
|
|
print $rv->error(), "\n";
|
|
|
|
return;
|
|
}
|
|
|
|
sub run_env
|
|
{
|
|
my $self = shift;
|
|
|
|
foreach ( sort @{ $self->{'env'} } ) {
|
|
print "$_: ";
|
|
print $conf->{$_} ? $conf->{$_} : 0;
|
|
print "\n"
|
|
}
|
|
}
|
|
|
|
sub run_grep
|
|
{
|
|
my $self = shift;
|
|
my ( $recurse, $filter, $base ) = @_;
|
|
|
|
# set 'recursion'
|
|
unless ( $recurse && $recurse =~ /\-r|recurse/ ) {
|
|
# shift args to the left
|
|
( $recurse, $filter, $base ) = ( undef, $recurse, $filter );
|
|
}
|
|
|
|
$filter = Net::LDAP::Filter->new( $filter );
|
|
unless ( $filter ) {
|
|
print "Invalid search filter.\n";
|
|
return;
|
|
}
|
|
|
|
# support '*'
|
|
$base = $self->base() if ! $base or $base eq '*';
|
|
|
|
unless ( $base ) {
|
|
print "No search base specified.\n";
|
|
return;
|
|
}
|
|
# convert base path to DN
|
|
$base = $self->path_to_dn($base, relative => 1);
|
|
|
|
$self->debug("Filter parsed as: " . $filter->as_string() . "\n");
|
|
|
|
my $s = $self->search(
|
|
{
|
|
scope => $recurse ? 'sub' : 'one',
|
|
base => $base,
|
|
filter => $filter
|
|
}
|
|
);
|
|
|
|
foreach my $e ( @{ $s->{'entries'} } ) {
|
|
my $dn = $e->dn();
|
|
print "$dn\n";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
# override internal help functions
|
|
# with pod2usage
|
|
#
|
|
sub run_help
|
|
{
|
|
return Pod::Usage::pod2usage(
|
|
-exitval => 'NOEXIT',
|
|
-verbose => 99,
|
|
-sections => 'SHELL COMMANDS'
|
|
);
|
|
}
|
|
|
|
sub run_list
|
|
{
|
|
my $self = shift;
|
|
my @args = @_;
|
|
my $base = $self->base();
|
|
my @attrs = ();
|
|
my $flags = '';
|
|
my $filter = '(objectclass=*)';
|
|
|
|
# parse arguments: [ <option> ...] [<filter> ...] [<attribute> ...]
|
|
if (@args) {
|
|
# options: support '-l' or '-R' listings
|
|
if ( $args[0] =~ /^\-([lR])/o ) {
|
|
$flags .= $1;
|
|
shift(@args);
|
|
}
|
|
|
|
my @filters;
|
|
|
|
# get filter elements from argument list
|
|
while (@args && $self->is_valid_filter($args[0])) {
|
|
push(@filters, shift(@args));
|
|
}
|
|
|
|
push(@filters, '(objectclass=*)') if (!@filters);
|
|
|
|
# construct OR'ed filter from filter elements
|
|
$filter = $self->make_filter( \@filters );
|
|
|
|
# remaining arguments must be attributes
|
|
push(@attrs, @args);
|
|
}
|
|
|
|
# flag booleans
|
|
my ( $recurse, $long );
|
|
if ( $flags ) {
|
|
$recurse = $flags =~ /R/o;
|
|
$long = $flags =~ /l/o;
|
|
push(@attrs, '*') if ($long && !@attrs);
|
|
}
|
|
|
|
my $s = $self->search({
|
|
scope => $recurse ? 'sub' : 'one',
|
|
vals => 1,
|
|
filter => $filter,
|
|
attrs => [ @attrs, 'hasSubordinates' ]
|
|
});
|
|
if ( $s->{'code'} ) {
|
|
print "$s->{'message'}\n";
|
|
return;
|
|
}
|
|
|
|
# if an entry doesn't have a description field,
|
|
# try and show some nice defaults for ls -l !
|
|
#
|
|
# objectClass -> Attribute to show
|
|
#
|
|
my %descs = %{
|
|
$conf->{'descmaps'}
|
|
|| {
|
|
posixAccount => 'gecos',
|
|
posixGroup => 'gidNumber',
|
|
ipHost => 'ipHostNumber',
|
|
}
|
|
};
|
|
|
|
# iterate and print
|
|
#
|
|
my $dn_count = 0;
|
|
foreach my $e ( sort { $a->dn() cmp $b->dn() } @{ $s->{'entries'} } ) {
|
|
my $dn = $e->dn();
|
|
|
|
# only show RDN unless -l was given
|
|
$dn = canonical_dn([shift(@{ldap_explode_dn($dn, casefold => 'none')})],
|
|
casefold => 'none')
|
|
unless ($long);
|
|
|
|
# if this entry is a container for other entries, append a
|
|
# trailing slash.
|
|
$dn .= '/' if ($e->get_value('hasSubordinates') eq 'TRUE');
|
|
|
|
# additional arguments given; show their values
|
|
if (@args) {
|
|
my @elements = ( $dn );
|
|
|
|
foreach my $attr (@args) {
|
|
my @vals = $e->get_value($attr);
|
|
push(@elements, join(',', @vals));
|
|
}
|
|
|
|
print join("\t", @elements)."\n";
|
|
}
|
|
else {
|
|
# show descriptions
|
|
my $desc = $e->get_value('description');
|
|
if ( $desc ) {
|
|
$desc =~ s/\n.*//s; # 1st line only
|
|
$dn .= " ($desc)";
|
|
}
|
|
|
|
# no desc? Try and infer something useful
|
|
# to display.
|
|
else {
|
|
|
|
# pull objectClasses, hash for lookup speed
|
|
my @oc = $e->get_value('objectClass');
|
|
my %ochash;
|
|
map { $ochash{$_} = 1 } @oc;
|
|
|
|
foreach my $d_listing ( sort keys %descs ) {
|
|
if ( exists $ochash{ $d_listing } ) {
|
|
my $str = $e->get_value( $descs{ $d_listing }, asref => 1 );
|
|
$dn .= ' (' . (join ', ', @$str) . ')' if $str && scalar @$str;
|
|
}
|
|
next;
|
|
}
|
|
}
|
|
print "$dn\n";
|
|
}
|
|
$dn_count++;
|
|
}
|
|
|
|
print "\n$dn_count " .
|
|
( $dn_count == 1 ? 'object.' : 'objects.') .
|
|
"\n" if $long;
|
|
return;
|
|
}
|
|
|
|
sub run_mkdir
|
|
{
|
|
my $self = shift;
|
|
my $dir = join ' ', @_;
|
|
my %ClassMap = ( c => 'country',
|
|
o => 'organization',
|
|
ou => 'organizationalUnit' );
|
|
my %class;
|
|
|
|
unless ( $dir ) {
|
|
print "No 'directory' provided.\n";
|
|
return;
|
|
}
|
|
|
|
# convert given path to DN
|
|
$dir = $self->path_to_dn($dir, relative => 1);
|
|
|
|
# normalize name, if it is not yet a legal DN
|
|
$dir = 'ou='.$dir if (!canonical_dn($dir));
|
|
|
|
# get RDN: naming attributes (lower-case) and their values
|
|
my %rdn = %{ shift(@{ ldap_explode_dn($dir, casefold => 'lower') }) };
|
|
|
|
# without RDN, return error
|
|
unless ( %rdn ) {
|
|
print "Illegal DN: $dir\n";
|
|
return;
|
|
}
|
|
|
|
# get objectclass from naming attributes
|
|
foreach my $attr (keys(%rdn)) {
|
|
map { $class{$ClassMap{$_}} = 1 if ($attr =~ /^\Q$_\E$/); }
|
|
keys(%ClassMap);
|
|
}
|
|
|
|
# fail if we did not get a unique objectclass
|
|
unless (scalar(keys(%class)) == 1) {
|
|
print "Unsupported DN: $dir\n";
|
|
return;
|
|
}
|
|
|
|
# create the new object
|
|
my $r = $self->ldap()->add($dir,
|
|
attr => [ objectClass => [ 'top', keys(%class) ],
|
|
%rdn ]);
|
|
|
|
print $r->error(), "\n";
|
|
$self->update_entries( clearcache => 1 );
|
|
return;
|
|
}
|
|
|
|
sub run_move
|
|
{
|
|
my $self = shift;
|
|
my ( $s_dn, $d_dn ) = @_;
|
|
|
|
unless ( $s_dn ) {
|
|
print "No source dn provided.\n";
|
|
return;
|
|
}
|
|
unless ( $d_dn ) {
|
|
print "No destination dn provided.\n";
|
|
return;
|
|
}
|
|
|
|
# convert given source path to DN
|
|
$s_dn = $self->path_to_dn($s_dn, relative => 1);
|
|
|
|
unless ( $self->is_valid_dn( $s_dn ) ) {
|
|
print "No such object\n";
|
|
return;
|
|
}
|
|
|
|
# see if we're moving the entry to a totally new path
|
|
my ( $new_dn, $old_dn );
|
|
( $d_dn, $new_dn ) = ( $1, $2 ) if $d_dn =~ /^([\w=]+),(.*)$/;
|
|
$old_dn = $1 if $s_dn =~ /^[\w=]+,(.*)$/;
|
|
|
|
my $rv = $self->ldap()->moddn(
|
|
$s_dn,
|
|
newrdn => $d_dn,
|
|
deleteoldrdn => 1,
|
|
newsuperior => $new_dn
|
|
);
|
|
print $rv->error(), "\n";
|
|
|
|
# clear caches
|
|
$self->{'cache'}->{ $new_dn } = {} if $new_dn;
|
|
$self->{'cache'}->{ $old_dn } = {} if $old_dn;
|
|
$self->update_entries( clearcache => 1 );
|
|
return;
|
|
}
|
|
|
|
sub run_passwd
|
|
{
|
|
my $self = shift;
|
|
my $dn = shift || $self->base();
|
|
|
|
$self->{'root_dse'} ||= $self->ldap->root_dse();
|
|
|
|
my $pw_extension = '1.3.6.1.4.1.4203.1.11.1';
|
|
unless ( $self->{'root_dse'}->supported_extension( $pw_extension ) ) {
|
|
print "Sorry, password changes not supported by LDAP server.\n";
|
|
return;
|
|
}
|
|
|
|
# convert given path to DN
|
|
$self->path_to_dn($dn, relative => 1);
|
|
|
|
my $s = $self->search( { base => $dn, scope => 'base' } );
|
|
if ( $s->{'code'} ) {
|
|
print $s->{'message'}, "\n";
|
|
return;
|
|
}
|
|
my $e = ${ $s->{'entries'} }[0];
|
|
|
|
unless ( $e->exists('userPassword') ) {
|
|
print "No userPassword attribute for $dn\n";
|
|
return;
|
|
}
|
|
|
|
print "Changing password for $dn\n";
|
|
Term::ReadKey::ReadMode 2;
|
|
print "Enter new password: ";
|
|
chomp( my $pw = <STDIN> );
|
|
print "\nRetype new password: ";
|
|
chomp( my $pw2 = <STDIN> );
|
|
print "\n";
|
|
Term::ReadKey::ReadMode 0;
|
|
|
|
if ( $pw ne $pw2 ) {
|
|
print "Sorry, passwords do not match.\n";
|
|
return;
|
|
}
|
|
|
|
my $rv = $self->ldap->set_password(
|
|
user => $dn,
|
|
newpasswd => $pw
|
|
);
|
|
|
|
if ( $rv->code() == LDAP_SUCCESS ) {
|
|
print "Password updated successfully.\n";
|
|
} else {
|
|
print "Password error: " . $rv->error() . "\n";
|
|
}
|
|
|
|
return;
|
|
}
|
|
|
|
sub run_pwd
|
|
{
|
|
my $self = shift;
|
|
print $self->base() . "\n";
|
|
return;
|
|
}
|
|
|
|
sub run_setenv
|
|
{
|
|
my $self = shift;
|
|
my ( $key, $val ) = @_;
|
|
|
|
( $key, $val ) = split /=/, $key if $key && ! defined $val;
|
|
return unless $key && defined $val;
|
|
$key = lc $key;
|
|
|
|
$conf->{$key} = $val;
|
|
return;
|
|
}
|
|
|
|
sub run_whoami
|
|
{
|
|
my $self = shift;
|
|
print $conf->{'binddn'} || 'anonymous bind';
|
|
print "\n";
|
|
return;
|
|
}
|
|
|
|
###############################################################
|
|
#
|
|
# MAIN
|
|
#
|
|
###############################################################
|
|
|
|
package main;
|
|
use strict;
|
|
use warnings;
|
|
|
|
$0 = 'shelldap';
|
|
my $VERSION = '0.4';
|
|
|
|
use Getopt::Long;
|
|
use YAML::Syck;
|
|
use Pod::Usage;
|
|
eval 'use Term::ReadLine::Gnu';
|
|
warn qq{Term::ReadLine::Gnu not installed.
|
|
Continuing, but shelldap is of limited usefulness without it.\n\n} if $@;
|
|
|
|
# get config - rc file first, command line overrides
|
|
use vars '$conf';
|
|
$conf = load_config() || {};
|
|
Getopt::Long::GetOptions(
|
|
$conf,
|
|
'server|H=s',
|
|
'binddn|D=s',
|
|
'basedn|b=s',
|
|
'cacheage=i',
|
|
'timeout=i',
|
|
'tls_cacert=s',
|
|
'tls_cert=s',
|
|
'tls_key=s',
|
|
'tls', 'debug',
|
|
help => sub {
|
|
Pod::Usage::pod2usage(
|
|
-verbose => 1,
|
|
-message => "\n$0 command line flags\n" . '-' x 65
|
|
);
|
|
}
|
|
);
|
|
|
|
# defaults
|
|
$conf->{'confpath'} = "$ENV{'HOME'}/.shelldap.rc";
|
|
$conf->{'cacheage'} ||= 300;
|
|
$conf->{'timeout'} ||= 10;
|
|
|
|
# create and enter shell loop
|
|
my $shell = LDAP::Shell->new();
|
|
$shell->cmdloop();
|
|
|
|
# load YAML config into global conf.
|
|
#
|
|
sub load_config
|
|
{
|
|
my ( $d, $data );
|
|
|
|
my $confpath;
|
|
my @confs = (
|
|
"$ENV{'HOME'}/.shelldap.rc",
|
|
'/usr/local/etc/shelldap.conf',
|
|
'/etc/shelldap.conf',
|
|
);
|
|
foreach ( @confs ) {
|
|
if ( -e $_ ) {
|
|
$confpath = $_;
|
|
last;
|
|
}
|
|
}
|
|
$confpath or return undef;
|
|
|
|
open YAML, $confpath or return undef;
|
|
do {
|
|
local $/ = undef;
|
|
$data = <YAML>; # slurp!
|
|
};
|
|
close YAML;
|
|
|
|
eval { $conf = YAML::Syck::Load( $data ) };
|
|
die "Invalid YAML in $confpath\n" if $@;
|
|
|
|
return $conf;
|
|
}
|
|
|
|
## EOF
|
|
|