#!/usr/bin/env perl
# mldapsearch --- my ldapsearch implementation

# Author: Noah Friedman <friedman@splode.com>
# Created: 2012-09-28
# Public domain.

# $Id: mldapsearch,v 1.19 2018/11/22 02:19:04 friedman Exp $

use strict;
use warnings qw(all);

use Net::LDAP;
use Net::LDAP::Util qw(:url);
use Net::LDAP::Constant qw(LDAP_REFERRAL);
use Getopt::Long;

use FindBin;
use lib "$FindBin::Bin/../../lib/perl";
use lib "$ENV{HOME}/lib/perl";

use NF::PrintObject;

our %opt = ( host      => 'ldap',
             base      => $ENV{LDAP_BASEDN},
             scope     => 'sub',  # base, one|single, sub|subtree, children
             deref     => 'never',  # never, search, find, always
             follow    => 1,

             onerror   => undef,  # undef, die, warn
             verify    => 'none',

             exclude   => [],
             oc_attrs  => [],     # only attrs from these object classes
             metaquery => '',
             andor     => '&',
             ldifclass => 'Net::LDAP::LDIF',
           );

our @default_search_attrs = (qw(uid cn));

our %andor_map = ( '&'   => '&',   '|'  => '|',
                   'a'   => '&',   'o'  => '|',
                   'and' => '&',   'or' => '|',
                 );

sub parse_options
{
  local *ARGV = \@{$_[0]}; # modify our local arglist, not real ARGV.

  # Precedence for defs (highest->lowest): options, rc file, default
  my @rc = ($ENV{MLDAPSEARCHRC},
            (defined $ENV{XDG_CONFIG_HOME}
             ? "$ENV{XDG_CONFIG_HOME}/mldapsearch.conf"
             : ()),
            "$ENV{HOME}/.mldapsearchrc");
  # The reason for these machinations is to avoid creating a new scoping
  # block in which the rc file is read.
  map { (do $_, goto readrc) if defined $_ && -f $_ } @rc;
 readrc:

  my $parser = Getopt::Long::Parser->new;
  $parser->configure (qw(bundling autoabbrev no_getopt_compat));
  my $succ = $parser->getoptions
    ( "debug:i"                => \$opt{debug},
      "dump-result"            => sub { $opt{dump}++ },

      "a|deref-aliases=s"      => \$opt{deref},
      "b|root|base=s"          => \$opt{base},
      "h|host=s",              => \$opt{host},
      "D|bind=s",              => \$opt{bind},
      "w|pswd|pass=s",         => \$opt{password},
      "p|port=s",              => \$opt{port},
      "s|scope=s",             => \$opt{scope},
      "P|cert=s"               => \$opt{cafile},

      "f|follow-referrals!"    => \$opt{follow},
      "n|sizelimit=i"          => \$opt{sizelimit},
      "wrap=i"                 => \$opt{wrap},
      "l|lowercase|lcase"      => \$opt{lcase},
      "sort!"                  => \$opt{sort},
      "types-only"             => \$opt{typesonly},

      "F"                      => sub { $opt{follow} = 0 },
      "E|exclude-attribute=s@" => \$opt{exclude},
      "O|oc|objectclass=s@"    => \$opt{oc_attrs},
      #"o|oa|operationalattr"  => \$opt{op_attrs},
      "j|andor=s"              => \$opt{cmd_andor}, # j=join
      "ldifclass=s"            => \$opt{ldifclass},

      "root-dse" => sub { $opt{metaquery} = 'rootdse' },
      "schema"   => sub { $opt{metaquery} = 'schema' },
    );

  # Let debug=0 indicate other debug output but not Net::LDAP class diags
  #$opt{debug} = 1 if defined $opt{debug} && $opt{debug} == 0;
  if ($opt{debug})
    {
      #$^W = 1;
      my $n = 0;
      map { $n = ($n << 1) | $_ } split //, $opt{debug};
      $opt{debug} = $n;
    }

  if ($opt{cmd_andor})
    {
      my $key = lc $opt{cmd_andor};
      $opt{andor} = $andor_map{$key} if exists $andor_map{$key};
    }

  map { $opt{$_} = [map { split (/(?:\s*,\s*)|\s+/, $_) } @{$opt{$_}}]
      } (qw(exclude oc_attrs));

  eval "require $opt{ldifclass}";
  die "$@" if $@ && $@ !~ /did not return a true value/;
}

# Query server to find out what directory roots are available, and if more
# than one, use hostname's fqdn to guess most likely relevant one.
sub baseDN
{
  my $ldap = shift;
  my $root_dse = get_root_dse ($ldap);

  # Try these first.  Active Directory specific?
  map { my @nc = $root_dse->get_value ($_);
        return $nc[0] if @nc;
      } (qw(defaultNamingContext rootDomainNamingContext));

  my @nc = $root_dse->get_value ("namingContexts");
  map { return $_ unless lc $_ eq "o=netscaperoot" } @nc if @nc <= 2;

  use POSIX qw(uname);
  my $nodename = (uname())[1];
  unless ($nodename =~ /\./) { # try to get FQDN
    my @n = gethostbyname ($nodename);
    if (@n) {
      for my $h ($n[0], split (/\s+/, $n[1])) {
        if ($h =~ /\./) {
          $nodename = $h;
          last;
        }
      }
    }
  }
  my @dc = split (/\./, lc $nodename);
  # Create "normalized" table by forcing lcase and stripping whitespace
  my %nc = map { my $key = lc $_;
                 $key =~ s/\s+//g;
                 $key => $_ ;
               } @nc;
  # Search for "dc=foo,dc=com" and "o=foo.com"
  map { map { return $nc{$_} if exists $nc{$_}
            } (join (",", map { "dc=$_" } @dc),
               "o=" . join (".", @dc));
        shift @dc;
      } @dc;

  return $nc[0]; # if all else fails, return first advertised.
}

sub LDIF
{
  my $data = shift;

  open (my $fh, '>', \my $buffer);
  my $ldif = $opt{ldifclass}->new ($fh, 'w', @_);
  $ldif->write_entry ($data);
  return $buffer;
}

sub get_root_dse
{
  my $ldap = shift;

  return $ldap->{m_rootdse} if defined $ldap->{m_rootdse};

  # Load root dse, including operational attributes.
  # Some servers support a '+' glob to return OAs, but others (like redhat
  # directory server) require them to be enumerated explicitly.
  @_ = (qw(* +
           altServer
           namingContexts
           subschemaSubentry
           supportedControl
           supportedExtension
           supportedFeatures
           supportedLDAPVersion
           supportedSASLMechanisms
           vendorName
           vendorVersion))
    unless @_;

  $ldap->{m_rootdse} = $ldap->root_dse (attrs => \@_);
}

# Useful schema attributes to query:
#	objectClasses
#	attributeTypes
#	matchingRules
#	matchingRuleUse
#	dITStructureRules
#	dITContentRules
#	nameForms
#	ldapSyntaxes
#	extendedAttributeInfo
sub do_schema_search
{
  my $ldap = shift;

  my $root = get_root_dse ($ldap);
  my $base = $root->get_value ('subschemaSubentry') || 'cn=schema';

  do_search ($ldap, { base => $base, scope  => 'base', },
             '(objectClass=subSchema)', @_);
}

sub do_search
{
  my ($ldap, $opt_override) = (shift, {});
  $opt_override = shift if @_ && ref ($_[0]) eq 'HASH';
  my %parm = (%opt, %$opt_override);

  @_ = (qw( (objectClass=*) * + )) unless @_;
  if (@_ && $_[0] !~ /=/)
    {
      my $val = shift;
      $parm{andor} = '|';
      unshift @_, map { sprintf ("%s=%s", $_, $val) } @default_search_attrs;
    }
  my (@srch, @attr);
  map { my $array = (/[<=>~]/ ? \@srch : \@attr);
        push @$array, $_;
      } @_;

  # Some non-compliant servers really dislike '(|(foo=bar))' if there is
  # only one subexpression.
  my @exprs = map { /^\(/ ? $_ : "($_)" } @srch;
  $parm{filter} = @exprs > 1 ? sprintf ("(%s%s)", $parm{andor}, join ("", @exprs)) : $exprs[0] ;

  $parm{attrs} = \@attr if @attr;
  $parm{base} = baseDN ($ldap) unless defined $parm{base};

  # An example of how one might use a control
  #use Net::LDAP::Control::ManageDsaIT;
  #my $manage = Net::LDAP::Control::ManageDsaIT->new ( critical => 0 );
  #push @{$parm{control}}, $manage;

  if (defined $parm{debug})
    {
      print STDERR "Search parameters:\n";
      my %dparm = map { $_ => $parm{$_}
                      } (qw(host bind
                            base scope deref
                            filter attrs exclude control
                          ));

      print STDERR object_pp (\%dparm), "\n";
    }

  my $result = $ldap->search (%parm);
  return unless $result;
  if ($result->is_error && $result->code != LDAP_REFERRAL)
    {
      print_ldap_err( $result );
      return unless $result->{entries};
    }
  return $result;
}

sub print_ldap_err
{
  my $result = shift;
  # Sometimes message has trailing NULs
  (my $msg = $result->{errorMessage}) =~ s/\0//;

  printf( STDERR "Error: %s\n%s\n%s\n",
          $result->error_name,
          $result->error_text,
          $msg );
  return;
}

sub do_print
{
  my ($result, $ldap, $opt) = (shift, shift, shift);

  my @lopt   = (change    => undef       );
  push @lopt,  (sort      => 1           ) if $opt->{sort};
  push @lopt,  (wrap      => $opt->{wrap}) if $opt->{wrap};
  push @lopt,  (lowercase => 1           ) if $opt->{lcase};

  # The default class doesn't do anything useful with schema.
  push @lopt,  (schema => $ldap->schema)
    if $opt->{ldifclass} ne 'Net::LDAP::LDIF' && $ldap && $ldap->can('schema');

  map { my $entry = $_;
        map { $entry->delete ($_) } @{$opt->{exclude}} if $entry->can ('delete');
        if ($opt->{dump})
          {
            my $obj = ($opt->{dump} > 1
                       ? $entry
                       : ($entry->{attrs}
                          ? $entry->{attrs}
                          : $entry->{asn}->{attributes}));
            print object_pp ($obj), "\n";
          }
        else
          { print LDIF ($entry, @lopt); }
      } ($result->can('entries') ? $result->entries : $result);
}

sub objectclass_attrs
{
  my $ldap = shift;

  return unless @_;
  my $ocs = $ldap->schema->{oc};
  map { my $elt = $ocs->{lc $_};
        map { if (ref ($elt->{$_}) eq 'ARRAY')
                { @{$elt->{$_}} }
              else
                { () }
            } (qw(must may));
      } @_;
}

sub new_ldap
{
  my $_opt = shift || \%opt;

  my $ldap = Net::LDAP->new ($_opt->{host}, %$_opt) || die "$@\n";
  $ldap->bind ($_opt->{bind}, %$_opt) if defined $_opt->{bind};
  return $ldap;
}

sub stdsearch
{
  my ($ldap, $opt) = (shift, shift);

  my $result = do_search( $ldap, $opt, @_ );
  return unless $result;
  if ($result->code == LDAP_REFERRAL)
    {
      return print_ldap_err( $result ) unless ($opt->{follow});
      for my $uri (@{$result->{referral}})
        {
          print( STDERR "** Following referral $uri\n" )
            if defined $opt->{debug};

          my %parsed = ldap_url_parse( $uri );
          my %nopt = (%$opt, %parsed,
                      scope  => $opt->{scope},
                      follow => 0);  # Don't follow nested referrals
          my $nldap = new_ldap( \%nopt );
          stdsearch( $nldap, \%nopt, @_ );
        }
    }
  else
    {
      do_print( $result, $ldap, $opt );
    }
}

sub main
{
  parse_options (\@_);
  my $ldap = new_ldap();

  if ($opt{metaquery} eq 'rootdse')
    {
      do_print (get_root_dse ($ldap, @_), $ldap, \%opt);
    }
  elsif ($opt{metaquery} eq 'schema')
    {
      if (@_)
        {
          push @_, objectclass_attrs ($ldap, @{$opt{oc_attrs}});
          my $result = do_schema_search ($ldap, @_);
          do_print ($result, $ldap, \%opt) if $result;
        }
      else
        {
          print object_pp ($ldap->schema), "\n";
        }
    }
  else
    {
      push @_, objectclass_attrs( $ldap, @{$opt{oc_attrs}} );
      stdsearch( $ldap, \%opt, @_ );
    }
}

main( @ARGV );

1;

__DATA__

__OperationalAttributes__

# Operational attributes are attributes that are used internally by the server.
# They generally can't be modified by a user, although one can read them.
#
# In order to retrieve an operational attribute, you have to require it
# explicitely, or request all of them using the special attribute '+'.
#
# RFC4512 defines four different flavors of AttributeType :
#
#     userApplications     : User attributes
#     directoryOperation   : Server attributes
#     dSAOperation         : Server attributes that are not meant to be distributed
#     distributedOperation : Server attributes that are used in a distributed environment

######
# Global operational attributes
# Operational attributes stored in the RootDSE, and global to the server.
######

# ATTRIBUTE		TYPE			CATEGORY 	DESCRIPTION
vendorName 		dSAOperation	 	Standard 	RFC3045: name of implementation vendor
vendorVersion		dSAOperation 		Standard 	RFC3045: version of implementation
entryTtl		dSAOperation 		Standard 	RFC2589: entry time-to-live
dynamicSubtrees		dSAOperation 		Standard 	RFC2589: dynamic subtrees
supportedFeatures	dSAOperation 		Standard 	RFC3674: features supported by the server
supportedControl	dSAOperation 		Standard 	RFC2252: supported controls
supportedSASLMechanisms	dSAOperation 		Standard 	RFC2252: supported SASL mechanisms
supportedLDAPVersion	dSAOperation 		Standard 	RFC2252: supported LDAP versions
namingContexts		dSAOperation 		Standard 	RFC2252: naming contexts
altServer		dSAOperation 		Standard 	RFC2252: alternative servers
supportedExtension	dSAOperation 		Standard 	RFC2252: supported extended operations

######
# Entry operational attributes
# Attributes related to an entry, carrying some additional information about it
######

# ATTRIBUTE	 	  TYPE 			CATEGORY 	DESCRIPTION
createTimestamp		directoryOperation 	Standard 	RFC2252: time which object was created
modifyTimestamp		directoryOperation 	Standard 	RFC2252: time which object was last modified
creatorsName		directoryOperation 	Standard 	RFC2252: name of creator
modifiersName		directoryOperation 	Standard 	RFC2252: name of last modifier
hasSubordinates		directoryOperation 	Standard 	X.501: entry has children
ref		 	distributedOperation 	Standard 	RFC3296: named reference - a labeledURI

entryUUID		directoryOperation 	ApacheDS 	UUID of the entry
entryDN			directoryOperation 	ApacheDS 	DN of the entry
entryCSN		directoryOperation 	ApacheDS 	Change sequence number of the entry
nbChildren		directoryOperation 	ApacheDS 	The number of children for this entry
nbSubordinates		directoryOperation 	ApacheDS 	The number of subordinates for this entry
entryParentId		directoryOperation 	ApacheDS 	Attribute holding the id of parent entry


######
# Schema-related operational attributes
# Attributes containing the schema elements handled by the server. They are stored in the RootDSE
######

# ATTRIBUTE	 	  TYPE			CATEGORY 	DESCRIPTION
ldapSyntaxes		directoryOperation 	Standard 	RFC2252: LDAP syntaxes
subschemaSubentry	directoryOperation 	Standard 	RFC2252: name of controlling subschema entry
dITStructureRules	directoryOperation 	Standard 	RFC2252: DIT structure rules
dITContentRules		directoryOperation 	Standard 	RFC2252: DIT content rules
matchingRules		directoryOperation 	Standard 	RFC2252: matching rules
attributeTypes		directoryOperation 	Standard 	RFC2252: attribute types
objectClasses		directoryOperation 	Standard 	RFC2252: object classes
nameForms		directoryOperation 	Standard 	RFC2252: Name Forms
matchingRuleUse		directoryOperation 	Standard 	RFC2252: matching rule uses
structuralObjectClass	directoryOperation 	Standard 	X.500(93): structural object class of entry

comparators		directoryOperation 	ApacheDS 	A multivalued comparator description attribute
normalizers		directoryOperation 	ApacheDS 	A multivalued normalizer description attribute
syntaxCheckers		directoryOperation 	ApacheDS 	A multivalued syntaxCheckers description attribute
schemaModifyTimestamp	directoryOperation 	ApacheDS 	Time which schema was modified
schemaModifiersName	directoryOperation 	ApacheDS 	The DN of the modifier of the schema

subordinateCount

######
# Collective Attributes operational attributes
######

# ATTRIBUTE			TYPE			CATEGORY 	DESCRIPTION
collectiveAttributeSubentries	directoryOperation	Standard	RFC3671: identifies all collective attribute subentries that affect the entry
collectiveExclusions		directoryOperation	Standard 	RFC3671: allows particular collective attributes to be excluded from an entry


######
# Administrative Model Attributes
######

# ATTRIBUTE		TYPE			CATEGORY 	DESCRIPTION
administrativeRole 	directoryOperation 	Standard 	RFC3672: indicate that the associated administrative area is concerned withone or more administrative roles
subtreeSpecification 	directoryOperation 	Standard 	RFC3672: defines a collection of entries within an administrative area
prescriptiveACI 	directoryOperation 	ApacheDS 	Access control information that applies to a set of entries
entryACI		directoryOperation	ApacheDS	Access control information that applies to a single entry
subentryACI		directoryOperation	ApacheDS	Access control information that applies to a single subentry
autonomousAreaSubentry 	directoryOperation 	ApacheDS 	Used to track a subentry associated with an autonomousArea
accessControlSubentries	directoryOperation	ApacheDS	Used to track a subentry associated with access control areas


######
# Replication related operational attributes
######

# ATTRIBUTE		TYPE			CATEGORY 	DESCRIPTION
contextCSN		directoryOperation 	ApacheDS 	The largest committed CSN of a context
entryDeleted		directoryOperation 	ApacheDS 	Whether or not an entry has been deleted. (Not anymore used)


######
# Index related operational attributes
######

# ATTRIBUTE		TYPE			CATEGORY 	DESCRIPTION
apachePresence		dSAOperation		ApacheDS 	Index attribute used to track the existence of attributes
apacheOneLevel		dSAOperation		ApacheDS 	Index attribute used to track one level searches
apacheOneAlias		dSAOperation		ApacheDS 	Index attribute used to track single level aliases
apacheSubAlias		dSAOperation		ApacheDS 	Index attribute used to track sub level aliases
apacheAlias		dSAOperation		ApacheDS 	Index attribute used to track aliases
apacheSubLevel		dSAOperation		ApacheDS 	Index attribute used to track sub level searches
apacheRdn		dSAOperation		ApacheDS 	Index attribute RDN with values both user provided and normalized based on schema


######
# Trigger interceptor Model Attributes
######

# ATTRIBUTE				TYPE			CATEGORY 	DESCRIPTION
prescriptiveTriggerSpecification	directoryOperation	ApacheDS 	Trigger specification that applies to a set of entries
entryTriggerSpecification		directoryOperation	ApacheDS 	Trigger specification that applies to a single entry
triggerExecutionSubentries		directoryOperation	ApacheDS 	Used to track subentries associated with a trigger area which an entry falls under

######
# ChangeLog related operational attributes
# They are not supposed to be distributed, and they can''t be modified by a user.
######

# ATTRIBUTE		TYPE			CATEGORY 	DESCRIPTION
revisions		dSAOperation		ApacheDS 	Revision numbers used in change log
changeTime		dSAOperation		ApacheDS 	Represents the time when the change event occurred
changeType		dSAOperation		ApacheDS 	Type of change operation
eventId			dSAOperation		ApacheDS 	The unique sequential id for the event (a.k.a revision number)
committer		dSAOperation		ApacheDS 	The principal committing the change
changeLogContext	dSAOperation		ApacheDS 	Tells about the changelog context suffix

######
# 389ds operational attributes
# These need proper categorization
######

accountUnlockTime		-			389ds		Time before user lockout expires and user can bind again
aci				directoryOperation	389ds		Access control
copiedFrom			distributedOperation	389ds		Legacy; Reference to master server for non-multimaster replication
copyingFrom			distributedOperation	389ds		Legacy; Reference to master server during replication in progress
entryUSN			-			389ds		Update sequence number for every client write operation
nsAccountLock			-			389ds		Whether the account is active or inactive

nsBackendSuffix			-			389ds		Suffix used by the backend
nsDS5ReplConflict		distributedOperation	389ds		Entry has unresolvable change conflict for replication
nsIdleTimeout			-			389ds		Bind connection idle timeout in seconds
nsLookThroughLimit		-			389ds		Max num of entries user can search per query
nsParentUniqueId		distributedOperation	389ds		DN or entry ID for the parent of the original entry for tombstones stored in replication
nsRole				-			389ds		Computed attribute (not stored) which identifies which roles an entry belongs
nsRoleDn			-			389ds		DN of all roles that apply to an entry
nsRoleFilter			-			Standard	RFC2252: Sets the filter identifies entries which belong to the role
nsSchemaCSN			-			389ds		subschema DSE attribute type
nsSizeLimit			-			389ds		Default size limit for a database or database link in bytes
nsTimeLimit			-			389ds		Default search time limit for a database or database link
nsUniqueId			-			389ds		Identifies or assigns a unique ID to a server entry
nscpEntryDN			-			389ds		Obsolete; used to be entry DN for a tombstone entry
nscpEntryDN			-			389ds		Orignal entry DN in a tombstone entry
numSubordinates			-			Standard	Indicates now many immediate subordinates an entry has.
passwordGraceUserTime		-			389ds		Number of attempts the user has made with the expired password.
passwordRetryCount		-			389ds		number Of consecutive failed attempts at entering the correct password
pwdpolicysubentry		-			389ds		Points to the entry DN of the new password policy

glue				objectClass		389ds		Entry is resurrected due to a replication conflict
ldapSubEntry			objectClass		Standard	Operational data
nsTombstone			objectClass		389ds		Entries which have been deleted

passwordObject			objectClass		389ds		Used for entries which store password information for a user
accountUnlockTime		passwordObject		389ds		Time that must pass after an account lockout before the user can bind
passwordAllowChangeTime		passwordObject		389ds		Time that must pass before users are allowed to change their passwords
passwordExpirationTime		passwordObject		389ds		Time that passes before the user password expires
passwordExpWarned		passwordObject		389ds		A password expiration warning has been sent to the user
passwordGraceUserTime		passwordObject		389ds		Number of allowed login attempts after the password has expired
passwordHistory			passwordObject		389ds		History of the user previous passwords
passwordRetryCount		passwordObject		389ds		Number of consecutive failed attempts entering correct password
pwdpolicysubentry		passwordObject		389ds		Entry DN of the new password policy
retryCountResetTime		passwordObject		389ds		Time that passes before the passwordRetryCount attribute is reset

nsAIMStatusGraphic		userApplications	389ds		Path to AIM user current status graphic
nsICQStatusGraphic		userApplications	389ds		Path to ICQ user current status graphic
nsYIMStatusGraphic		userApplications	389ds		Path to Yahoo IM user current status graphic

nsAIMStatusText			userApplications	389ds		Text of AIM user current status
nsICQStatusText			userApplications	389ds		Text of ICQ user current status
nsYIMStatusText			userApplications	389ds		Text of Yahoo IM user current status

# eof
