#!/usr/bin/env ruby require 'yaml' require 'abbrev' require 'trollop' require 'highline' require 'shellwords' # Work around MacOS X's vendored 'sysexits' that does the same thing, # but with a different API gem 'sysexits' require 'sysexits' require 'treequel' require 'treequel/mixins' require 'treequel/constants' # A tool for displaying information about a directory's records and schema artifacts. class Treequel::What extend Sysexits include Sysexits, Treequel::Loggable, Treequel::ANSIColorUtilities, Treequel::Constants::Patterns, Treequel::HashUtilities COLOR_SCHEME = HighLine::ColorScheme.new do |scheme| scheme[:header] = [ :bold, :yellow ] scheme[:subheader] = [ :bold, :white ] scheme[:key] = [ :white ] scheme[:value] = [ :bold, :white ] scheme[:error] = [ :red ] scheme[:warning] = [ :yellow ] scheme[:message] = [ :reset ] end ### Run the utility with the given +args+. def self::run( args ) HighLine.color_scheme = COLOR_SCHEME oparser = self.make_option_parser opts = Trollop.with_standard_exception_handling( oparser ) do oparser.parse( args ) end pattern = oparser.leftovers.join( ' ' ) if oparser.leftovers self.new( opts ).run( pattern ) exit :ok rescue => err Treequel.logger.fatal "Oops: %s: %s" % [ err.class.name, err.message ] Treequel.logger.debug { ' ' + err.backtrace.join("\n ") } exit :software_error end ### Create and configure a command-line option parser for the command. ### @return [Trollop::Parser] the option parser def self::make_option_parser progname = File.basename( $0 ) default_directory = Treequel.directory_from_config loglevels = Treequel::LOG_LEVELS. sort_by {|name,lvl| lvl }. collect {|name,lvl| name.to_s }. join( ', ' ) return Trollop::Parser.new do banner "Usage: #{progname} [OPTIONS] [PATTERN]" text '' text %{Search for an object in an LDAP directory that matches PATTERN and } + %{display some information about it.} text '' text %{The PATTERN can be the DN (or RDN relative to the base) of an entry, } + %{a search filter, or the name of an artifact in the directory's schema, } + %{such as an objectClass, matching rule, syntax, etc.} text '' text %{If no PATTERN is specified, general information about the directory is } + %{output instead.} text '' text 'Connection Options:' opt :ldapurl, "Specify the directory to connect to.", :default => default_directory.uri.to_s text '' text 'Display Options:' opt :attrtypes, "Show attribute types for objects that have them." opt :objectclasses, "Show objectclasses for objects that have them." opt :syntaxes, "Show syntaxes for objects that have them." opt :matching_rules, "Show matching rules for objects that have them." opt :matching_rule_uses, "Show matching rule uses for objects that have them." opt :all, "Show any of the above that are applicable." text '' text 'Other Options:' opt :debug, "Turn debugging on. Also sets the --loglevel to 'debug'." opt :loglevel, "Set the logging level. Must be one of: #{loglevels}", :default => Treequel::LOG_LEVEL_NAMES[ Treequel.logger.level ] opt :binddn, "The DN of the user to bind as. Defaults to anonymous binding.", :type => :string end end ################################################################# ### I N S T A N C E M E T H O D S ################################################################# ### Create a new instance of the command and set it up with the given ### +options+. def initialize( options ) Treequel.logger.formatter = Treequel::ColorLogFormatter.new( Treequel.logger ) if options.debug $DEBUG = true $VERBOSE = true Treequel.logger.level = Logger::DEBUG elsif options.loglevel Treequel.logger.level = Treequel::LOG_LEVELS[ options.loglevel ] end @options = options if @options.all? @options[:attrtypes] = @options[:objectclasses] = @options[:syntaxes] = @options[:matching_rules] = @options[:matching_rule_uses] = true end @directory = Treequel.directory( options.ldapurl ) @prompt = HighLine.new @prompt.wrap_at = @prompt.output_cols - 10 self.log.debug "Created new treewhat command object for %s" % [ @directory ] end ###### public ###### # The LDAP directory the command will connect to attr_reader :directory # The Trollop options hash the command will read its configuration from attr_reader :options # The HighLine object to use for prompting and displaying stuff attr_reader :prompt ### Display an +object+ highlighted as a header. def print_header( object ) self.prompt.say( self.prompt.color(object.to_s, :header) ) end ### Run the command with the specified +pattern+. def run( pattern=nil ) self.log.debug "Running with pattern = %p" % [ pattern ] self.bind_to_directory if self.options.binddn case pattern # No argument when NilClass, '' self.show_directory_overview # DN/RDN or filter if it contains a '=' when /=/ self.show_entry( pattern ) # Otherwise, try to find a schema item that matches else self.show_schema_artifact( pattern ) end end ### Prompt for a password and then bind to the command's directory using the binddn in ### the options. def bind_to_directory binddn = self.options.binddn or raise ArgumentError, "no binddn in the options hash?!" self.log.debug "Attempting to bind to the directory as %s" % [ binddn ] pass = self.prompt.ask( "password: " ) {|q| q.echo = '*' } user = Treequel::Branch.new( self.directory, binddn ) self.directory.bind_as( user, pass ) self.log.debug " bound as %s" % [ user ] return true end ### Show general information about the directory if the user doesn't give a pattern on ### the command line. def show_directory_overview pr = self.prompt dir = self.directory self.print_header( dir.uri.to_s ) pr.say( "\n" ) pr.say( dir.schema.to_s ) self.show_column_list( dir.schema.attribute_types.values, 'Attribute Types' ) if self.options.attrtypes self.show_column_list( dir.schema.object_classes.values, "Object Classes" ) if self.options.objectclasses self.show_column_list( dir.schema.ldap_syntaxes.values, "Syntaxes" ) if self.options.syntaxes self.show_column_list( dir.schema.matching_rules.values, "Matching Rules" ) if self.options.matching_rules self.show_column_list( dir.schema.matching_rule_uses.values, "Matching Rule Uses" ) if self.options.matching_rule_uses end ### Show the items from the given +enum+ under the specified +subheading+ in a columnized list. def show_column_list( enum, subheading ) pr = self.prompt items = nil if enum.first.respond_to?( :name ) items = enum.map( &:name ).map( &:to_s ).uniq else items = enum.map( &:oid ).uniq end pr.say( "\n" ) pr.say( pr.color(subheading, :subheader) ) pr.say( pr.list(items.sort_by(&:downcase), :columns_across) ) end # # 'Show entry' mode # ### Fetch an entry from the directory and display it like Treequel's editing mode. def show_entry( pattern ) dir = self.directory branch = Treequel::Branch.new( dir, pattern ) if !branch.exists? branch = Treequel::Branch.new( dir, pattern + ',' + dir.base_dn ) end if !branch.exists? branch = dir.filter( pattern ).first end if !branch self.prompt.say( self.prompt.color("No match.", :error) ) end yaml = self.branch_as_yaml( branch ) self.prompt.say( yaml ) end ### Return the specified Treequel::Branch object as YAML. If +include_operational+ is true, ### include the entry's operational attributes. If +extra_objectclasses+ contains ### one or more objectClass OIDs, include their MUST and MAY attributes when building the ### YAML representation of the branch. def branch_as_yaml( object, include_operational=false ) object.include_operational_attrs = include_operational # Make sure the displayed entry has the MUST attributes entryhash = stringify_keys( object.must_attributes_hash ) entryhash.merge!( object.entry || {} ) entryhash['objectClass'] ||= [] entryhash.delete( 'dn' ) # Special attribute, can't be edited yaml = entryhash.to_yaml yaml[ 5, 0 ] = self.prompt.color( "# #{object.dn}\n", :header ) # Make comments out of MAY attributes that are unset mayhash = stringify_keys( object.may_attributes_hash ) self.log.debug "MAY hash is: %p" % [ mayhash ] mayhash.delete_if {|attrname,val| entryhash.key?(attrname) } yaml << mayhash.to_yaml[5..-1].gsub( /\n\n/, "\n" ).gsub( /^/, '# ' ) return yaml end # # 'Show schema artifact' mode # SCHEMA_ARTIFACT_TYPES = [ :object_classes, :attribute_types, :ldap_syntaxes, :matching_rules, :matching_rule_uses, ] ### Find an artifact in the directory's schema that matches +pattern+, and display it ### if it exists. def show_schema_artifact( pattern ) pr = self.prompt schema = self.directory.schema artifacts = SCHEMA_ARTIFACT_TYPES. collect {|type| schema.send( type ).values.uniq }.flatten if match = find_exact_matching_artifact( artifacts, pattern ) self.display_schema_artifact( match ) elsif match = find_substring_matching_artifact( artifacts, pattern ) pr.say( "No exact match. Falling back to substring match:" ) self.display_schema_artifact( match ) else pr.say( pr.color("No match.", :error) ) end end ### Display a schema artifact in a readable way. def display_schema_artifact( artifact ) self.prompt.say( self.prompt.color(artifact.class.name.sub(/.*::/, ''), :header) + ' ' ) self.prompt.say( self.prompt.color(artifact.to_s, :subheader) ) # Display some other stuff depending on what kind of thing it is case artifact when Treequel::Schema::AttributeType self.display_attrtype_details( artifact ) end end ### Display additional details for the specified +attrtype+. ### @param [Treequel::Schema::AttributeType] attrtype the attribute type object to display def display_attrtype_details( attrtype ) ocs = self.directory.schema.object_classes.values.find_all do |oc| ( oc.must_oids | oc.may_oids ).include?( attrtype.name.to_sym ) end if ocs.empty? self.prompt.say "No objectClasses with the '%s' attribute are in the current schema." % [ attrtype.name ] else ocnames = ocs.uniq.map( &:name ).map( &:to_s ).sort self.prompt.say "objectClasses with the '%s' attribute in the current schema:" % [ attrtype.name ] self.prompt.say( self.prompt.list(ocnames, :columns_across) ) end end ### Try to find an artifact in +artifacts+ whose name or oid matches +pattern+ exactly. ### Returns the first matching artifact. def find_exact_matching_artifact( artifacts, pattern ) self.log.debug "Trying to find an exact match for %p in %d artifacts." % [ pattern, artifacts.length ] return artifacts.find do |obj| (obj.respond_to?( :names ) && obj.names.map(&:to_s).include?(pattern) ) || (obj.respond_to?( :name ) && obj.name.to_s == pattern ) || (obj.respond_to?( :oid ) && obj.oid == pattern ) end end ### Try to find an artifact in +artifacts+ whose name or oid contains +pattern+. ### Returns the first matching artifact. def find_substring_matching_artifact( artifacts, pattern ) pattern = Regexp.new( Regexp.escape(pattern), Regexp::IGNORECASE ) return artifacts.find do |obj| (obj.respond_to?( :names ) && obj.names.find {|name| name.to_s =~ pattern} ) || (obj.respond_to?( :name ) && obj.name.to_s =~ pattern ) || (obj.respond_to?( :oid ) && obj.oid =~ pattern ) end end end # class Treequel::What Treequel::What.run( ARGV.dup )