lib/whimsy/asf/ldap.rb in whimsy-asf-0.0.74 vs lib/whimsy/asf/ldap.rb in whimsy-asf-0.0.75

- old
+ new

@@ -1,50 +1,121 @@ +# +# Encapsulate access to LDAP, caching results for performance. For best +# performance in applications that access large number of objects, make use +# of the preload methods to pre-fetch multiple objects in a single LDAP +# call, and rely on the cache to find the objects later. +# +# The cache makes heavy use of Weak References internally to enable garbage +# collection to reclaim objects; among other things, this ensures that +# LDAP results don't become too stale. +# +# Until garbage collection reclaims an object, calls to find methods for the +# same name is guaranteed to return the same object. Holding on to the +# results of find or preload calls (by assigning it to a variable) is +# sufficient to prevent reclaiming of objects. +# +# To illustrate, the following is likely to return the same id twice, followed +# by a new id: +# puts ASF::Person.find('rubys').__id__ +# puts ASF::Person.find('rubys').__id__ +# GC.start +# puts ASF::Person.find('rubys').__id__ +# +# By contrast, the following is guaranteed to produce the same id three times: +# rubys1 = ASF::Person.find('rubys') +# rubys2 = ASF::Person.find('rubys') +# GC.start +# rubys3 = ASF::Person.find('rubys') +# puts [rubys1.__id__, rubys2.__id__, rubys3.__id__] +# + require 'wunderbar' require 'ldap' require 'weakref' +require 'net/http' +require 'base64' module ASF module LDAP # https://www.pingmybox.com/dashboard?location=304 # https://github.com/apache/infrastructure-puppet/blob/deployment/data/common.yaml (ldapserver::slapd_peers) HOSTS = %w( ldaps://ldap1-us-west.apache.org:636 - ldaps://ldap1-eu-central.apache.org:636 + ldaps://ldap1-lw-us.apache.org:636 ldaps://ldap2-us-west.apache.org:636 - ldaps://ldap1-us-east.apache.org:636 + ldaps://ldap1-lw-eu.apache.org:636 ldaps://snappy5.apache.org:636 + ldaps://ldap2-lw-us.apache.org:636 + ldaps://ldap2-lw-eu.apache.org:636 ) - end - # determine where ldap.conf resides - if Dir.exist? '/etc/openldap' - ETCLDAP = '/etc/openldap' - else - ETCLDAP = '/etc/ldap' - end + # fetch configuration from apache/infrastructure-puppet + def self.puppet_config + return @puppet if @puppet + file = '/apache/infrastructure-puppet/deployment/data/common.yaml' + http = Net::HTTP.new('raw.githubusercontent.com', 443) + http.use_ssl = true + @puppet = YAML.load(http.request(Net::HTTP::Get.new(file)).body) + end - # determine whether or not the LDAP API can be used - def self.init_ldap - @ldap = nil - @mtime = Time.now + # extract the ldapcert from the puppet configuration + def self.puppet_cert + puppet_config['ldapclient::ldapcert'] + end - host = ASF::LDAP.host + # extract the ldap servers from the puppet configuration + def self.puppet_ldapservers + puppet_config['ldapserver::slapd_peers'].values. + map {|host| "ldaps://#{host}:636"} + rescue + nil + end - Wunderbar.info "Connecting to LDAP server: #{host}" + # connect to LDAP + def self.connect + hosts.shuffle.each do |host| + Wunderbar.info "Connecting to LDAP server: #{host}" - begin - uri = URI.parse(host) - if uri.scheme == 'ldaps' - @ldap = ::LDAP::SSLConn.new(uri.host, uri.port) - else - @ldap = ::LDAP::Conn.new(uri.host, uri.port) + begin + # request connection + uri = URI.parse(host) + if uri.scheme == 'ldaps' + ldap = ::LDAP::SSLConn.new(uri.host, uri.port) + else + ldap = ::LDAP::Conn.new(uri.host, uri.port) + end + + # test the connection + ldap.bind + + # save the host + @host = host + + return ldap + rescue ::LDAP::ResultError => re + Wunderbar.warn "Error connecting to LDAP server #{host}: " + + re.message + " (continuing)" + end + end - rescue ::LDAP::ResultError=>re - Wunderbar.error "Error binding to LDAP server: message: ["+ re.message + "]" + Wunderbar.error "Failed to connect to any LDAP host" + return nil end end + # backwards compatibility for tools that called this interface + def self.init_ldap + @ldap ||= ASF::LDAP.connect + end + + # determine where ldap.conf resides + if Dir.exist? '/etc/openldap' + ETCLDAP = '/etc/openldap' + else + ETCLDAP = '/etc/ldap' + end + def self.ldap @ldap || self.init_ldap end # search with a scope of one @@ -64,34 +135,38 @@ result.map! {|hash| hash[attrs]} if String === attrs result end - def self.refresh(symbol) - if not @mtime or Time.now - @mtime > 300.0 - @mtime = Time.now + # safely dereference a weakref array attribute. Block provided is + # used when reference is not set or has been reclaimed. + def self.dereference_weakref(object, attr, &block) + attr = "@#{attr}" + value = object.instance_variable_get(attr) || block.call + value[0..-1] + rescue WeakRef::RefError + value = block.call + ensure + if value and not value.instance_of? WeakRef + object.instance_variable_set(attr, WeakRef.new(value)) end + end - if instance_variable_get("#{symbol}_mtime") != @mtime - instance_variable_set("#{symbol}_mtime", @mtime) - instance_variable_set(symbol, nil) - end + def self.weakref(attr, &block) + self.dereference_weakref(self, attr, &block) end def self.pmc_chairs - refresh(:@pmc_chairs) - @pmc_chairs ||= Service.find('pmc-chairs').members + weakref(:pmc_chairs) {Service.find('pmc-chairs').members} end def self.committers - refresh(:@committers) - @committers ||= Group.find('committers').members + weakref(:committers) {Group.find('committers').members} end def self.members - refresh(:@members) - @members ||= Group.find('member').members + weakref(:members) {Group.find('member').members} end class Base attr_reader :name @@ -132,10 +207,14 @@ def reference self end + def weakref(attr, &block) + ASF.dereference_weakref(self, attr, &block) + end + unless Object.respond_to? :id def id @name end end @@ -211,16 +290,20 @@ def asf_member? ASF::Member.status[name] or ASF.members.include? self end + def asf_officer_or_member? + asf_member? or ASF.pmc_chairs.include? self + end + def asf_committer? ASF::Group.new('committers').include? self end def banned? - not attrs['loginShell'] or attrs['loginShell'].include? "/usr/bin/false" + not attrs['loginShell'] or %w(/usr/bin/false bin/nologin bin/no-cla).any? {|a| attrs['loginShell'].first.include? a} end def mail attrs['mail'] || [] end @@ -297,26 +380,66 @@ else return true end end + def self.preload + Hash[ASF.search_one(base, "cn=*", %w(dn memberUid modifyTimestamp)).map do |results| + cn = results['dn'].first[/^cn=(.*?),/, 1] + group = ASF::Group.find(cn) + group.modifyTimestamp = results['modifyTimestamp'].first # it is returned as an array of 1 entry + members = results['memberUid'] + group.members = members || [] + [group, members] + end] + end + + attr_accessor :modifyTimestamp + + def members=(members) + @members = WeakRef.new(members) + end + def members - ASF.search_one(base, "cn=#{name}", 'memberUid').flatten. - map {|uid| Person.find(uid)} + members = weakref(:members) do + ASF.search_one(base, "cn=#{name}", 'memberUid').flatten + end + + members.map {|uid| Person.find(uid)} end end class Committee < Base @base = 'ou=pmc,ou=committees,ou=groups,dc=apache,dc=org' def self.list(filter='cn=*') ASF.search_one(base, filter, 'cn').flatten.map {|cn| Committee.find(cn)} end + def self.preload + Hash[ASF.search_one(base, "cn=*", %w(dn member modifyTimestamp)).map do |results| + cn = results['dn'].first[/^cn=(.*?),/, 1] + committee = ASF::Committee.find(cn) + committee.modifyTimestamp = results['modifyTimestamp'].first # it is returned as an array of 1 entry + members = results['member'] + committee.members = members + [committee, members] + end] + end + + attr_accessor :modifyTimestamp + + def members=(members) + @members = WeakRef.new(members) + end + def members - ASF.search_one(base, "cn=#{name}", 'member').flatten. - map {|uid| Person.find uid[/uid=(.*?),/,1]} + members = weakref(:members) do + ASF.search_one(base, "cn=#{name}", 'member').flatten + end + + members.map {|uid| Person.find uid[/uid=(.*?),/,1]} end def dn @dn ||= ASF.search_one(base, "cn=#{name}", 'dn').first.first end @@ -352,61 +475,114 @@ end module LDAP def self.bind(user, password, &block) dn = ASF::Person.new(user).dn + raise ::LDAP::ResultError.new('Unknown user') unless dn + + ASF.ldap.unbind rescue nil if block ASF.ldap.bind(dn, password, &block) + ASF.init_ldap else ASF.ldap.bind(dn, password) end - ASF.init_ldap end - # select LDAP host - def self.host + # validate HTTP authorization, and optionally invoke a block bound to + # that user. + def self.http_auth(string, &block) + auth = Base64.decode64(string.to_s[/Basic (.*)/, 1] || '') + user, password = auth.split(':', 2) + return unless password + + if block + self.bind(user, password, &block) + else + begin + ASF::LDAP.bind(user, password) {} + return ASF::Person.new(user) + rescue ::LDAP::ResultError + return nil + end + end + end + + # determine what LDAP hosts are available + def self.hosts # try whimsy config - host = ASF::Config.get(:ldap) + hosts = Array(ASF::Config.get(:ldap)) # check system configuration - unless host + if hosts.empty? conf = "#{ETCLDAP}/ldap.conf" if File.exist? conf - host = File.read(conf)[/^uri\s+(ldaps?:\/\/\S+?:\d+)/i, 1] + uris = File.read(conf)[/^uri\s+(.*)/i, 1].to_s + hosts = uris.scan(/ldaps?:\/\/\S+?:\d+/) end end - # if all else fails, pick one at random - host = ASF::LDAP::HOSTS.sample unless host + # if all else fails, use default list + hosts = ASF::LDAP::HOSTS if hosts.empty? - host + hosts end + # select LDAP host + def self.host + @host ||= hosts.sample + end + # query and extract cert from openssl output - def self.cert + def self.extract_cert host = LDAP.host[%r{//(.*?)(/|$)}, 1] - query = "openssl s_client -connect #{host} -showcerts" - output = `#{query} < /dev/null 2> /dev/null` - output[/^-+BEGIN.*?\n-+END[^\n]+\n/m] + puts ['openssl', 's_client', '-connect', host, '-showcerts'].join(' ') + out, err, rc = Open3.capture3 'openssl', 's_client', + '-connect', host, '-showcerts' + out[/^-+BEGIN.*?\n-+END[^\n]+\n/m] end # update /etc/ldap.conf. Usage: + # # sudo ruby -r whimsy/asf -e "ASF::LDAP.configure" + # def self.configure - if not File.exist? "#{ETCLDAP}/asf-ldap-client.pem" - File.write "#{ETCLDAP}/asf-ldap-client.pem", self.cert + cert = Dir["#{ETCLDAP}/asf*-ldap-client.pem"].first + + # verify/obtain/write the cert + if not cert + cert = "#{ETCLDAP}/asf-ldap-client.pem" + File.write cert, ASF::LDAP.puppet_cert || self.extract_cert end + # read the current configuration file ldap_conf = "#{ETCLDAP}/ldap.conf" content = File.read(ldap_conf) - unless content.include? 'asf-ldap-client.pem' - content.gsub!(/^TLS_CACERT/, '# TLS_CACERT') - content.gsub!(/^TLS_REQCERT/, '# TLS_REQCERT') + + # ensure that the right cert is used + unless content =~ /asf.*-ldap-client\.pem/ + content.gsub!(/^TLS_CACERT/i, '# TLS_CACERT') content += "TLS_CACERT #{ETCLDAP}/asf-ldap-client.pem\n" - content += "uri #{LDAP.host}\n" + end + + # provide the URIs of the ldap hosts + content.gsub!(/^URI/, '# URI') + content += "uri \n" unless content =~ /^uri / + content[/uri (.*)\n/, 1] = hosts.join(' ') + + # verify/set the base + unless content.include? 'base dc=apache' + content.gsub!(/^BASE/i, '# BASE') content += "base dc=apache,dc=org\n" - content += "TLS_REQCERT allow\n" if ETCLDAP.include? 'openldap' - File.write(ldap_conf, content) end + + # ensure TLS_REQCERT is allow (Mac OS/X only) + if ETCLDAP.include? 'openldap' and not content.include? 'REQCERT allow' + content.gsub!(/^TLS_REQCERT/i, '# TLS_REQCERT') + content += "TLS_REQCERT allow\n" + end + + # write the configuration if there were any changes + File.write(ldap_conf, content) unless content == File.read(ldap_conf) end end end