This commit is contained in:
pvincent
2026-06-05 11:20:14 +00:00
commit 79d31f61c4
50 changed files with 6127 additions and 0 deletions
+257
View File
@@ -0,0 +1,257 @@
require 'thread'
require 'openssl'
require 'ldap/server/result'
module LDAP
class Server
# An object which handles an LDAP connection. Note that LDAP allows
# requests and responses to be exchanged asynchronously: e.g. a client
# can send three requests, and the three responses can come back in
# any order. For that reason, we start a new thread for each request,
# and we need a mutex on the io object so that multiple responses don't
# interfere with each other.
class Connection
attr_reader :binddn, :version, :opt
def initialize(io, opt={})
@io = io
@opt = opt
@mutex = Mutex.new
@threadgroup = ThreadGroup.new
@binddn = nil
@version = 3
@logger = @opt[:logger]
@ssl = false
startssl if @opt[:ssl_on_connect]
end
def log(msg, severity = Logger::INFO)
@logger.add(severity, msg, @io.peeraddr[3])
end
def debug msg
log msg, Logger::DEBUG
end
def log_exception(e)
log "#{e}: #{e.backtrace.join("\n\tfrom ")}", Logger::ERROR
end
def startssl # :yields:
@mutex.synchronize do
raise LDAP::ResultError::OperationsError if @ssl or @threadgroup.list.size > 0
yield if block_given?
@io = OpenSSL::SSL::SSLSocket.new(@io, @opt[:ssl_ctx])
@io.sync_close = true
@io.accept
@ssl = true
end
end
# Read one ASN1 element from the given stream.
# Return String containing the raw element.
def ber_read(io)
blk = io.read(2) # minimum: short tag, short length
throw(:close) if blk.nil?
codepoints = blk.respond_to?(:codepoints) ? blk.codepoints.to_a : blk
tag = codepoints[0] & 0x1f
len = codepoints[1]
if tag == 0x1f # long form
tag = 0
while true
ch = io.getc
blk << ch
tag = (tag << 7) | (ch & 0x7f)
break if (ch & 0x80) == 0
end
len = io.getc
blk << len
end
if (len & 0x80) != 0 # long form
len = len & 0x7f
raise LDAP::ResultError::ProtocolError, "Indefinite length encoding not supported" if len == 0
offset = blk.length
blk << io.read(len)
# is there a more efficient way of doing this?
len = 0
blk[offset..-1].each_byte { |b| len = (len << 8) | b }
end
offset = blk.length
blk << io.read(len)
return blk
# or if we wanted to keep the partial decoding we've done:
# return blk, [blk[0] >> 6, tag], offset
end
def handle_requests
catch(:close) do
while true
begin
blk = ber_read(@io)
asn1 = OpenSSL::ASN1::decode(blk)
# Debugging:
# puts "Request: #{blk.unpack("H*")}\n#{asn1.inspect}" if $debug
raise LDAP::ResultError::ProtocolError, "LDAPMessage must be SEQUENCE" unless asn1.is_a?(OpenSSL::ASN1::Sequence)
raise LDAP::ResultError::ProtocolError, "Bad Message ID" unless asn1.value[0].is_a?(OpenSSL::ASN1::Integer)
messageId = asn1.value[0].value
protocolOp = asn1.value[1]
raise LDAP::ResultError::ProtocolError, "Bad protocolOp" unless protocolOp.is_a?(OpenSSL::ASN1::ASN1Data)
raise LDAP::ResultError::ProtocolError, "Bad protocolOp tag class" unless protocolOp.tag_class == :APPLICATION
# controls are not properly implemented
c = asn1.value[2]
if c.is_a?(OpenSSL::ASN1::ASN1Data) and c.tag_class == :APPLICATION and c.tag == 0
controls = c.value
end
case protocolOp.tag
when 0 # BindRequest
abandon_all
if @opt[:router]
@binddn, @version = @opt[:router].do_bind(self, messageId, protocolOp, controls)
else
operationClass = @opt[:operation_class]
ocArgs = @opt[:operation_args] || []
@binddn, @version = operationClass.new(self,messageId,*ocArgs).
do_bind(protocolOp, controls)
end
when 2 # UnbindRequest
throw(:close)
when 3 # SearchRequest
start_op(messageId,protocolOp,controls,:do_search)
when 6 # ModifyRequest
start_op(messageId,protocolOp,controls,:do_modify)
when 8 # AddRequest
start_op(messageId,protocolOp,controls,:do_add)
when 10 # DelRequest
start_op(messageId,protocolOp,controls,:do_del)
when 12 # ModifyDNRequest
start_op(messageId,protocolOp,controls,:do_modifydn)
when 14 # CompareRequest
start_op(messageId,protocolOp,controls,:do_compare)
when 16 # AbandonRequest
abandon(protocolOp.value)
else
raise LDAP::ResultError::ProtocolError, "Unrecognised protocolOp tag #{protocolOp.tag}"
end
rescue LDAP::ResultError::ProtocolError, OpenSSL::ASN1::ASN1Error => e
send_notice_of_disconnection(LDAP::ResultError::ProtocolError.new.to_i, e.message)
throw(:close)
# all other exceptions propagate up and are caught by tcpserver
end
end
end
abandon_all
end
# Start an operation in a Thread. Add this to a ThreadGroup to allow
# the operation to be abandoned later.
#
# When the thread terminates, it automatically drops out of the group.
#
# Note: RFC 2251 4.4.4.1 says behaviour is undefined if
# client sends an overlapping request with same message ID,
# so we don't have to worry about the case where there is
# already a thread with this messageId in @threadgroup.
def start_op(messageId,protocolOp,controls,meth)
operationClass = @opt[:operation_class]
ocArgs = @opt[:operation_args] || []
thr = Thread.new do
begin
if @opt[:router]
@opt[:router].send meth, self, messageId, protocolOp, controls
else
operationClass.new(self,messageId,*ocArgs).
send(meth,protocolOp,controls)
end
rescue Exception => e
log_exception e
end
end
thr[:messageId] = messageId
@threadgroup.add(thr)
end
def write(data)
@mutex.synchronize do
@io.write(data)
@io.flush
end
end
def writelock
@mutex.synchronize do
yield @io
@io.flush
end
end
def abandon(messageID)
@mutex.synchronize do
thread = @threadgroup.list.find { |t| t[:messageId] == messageID }
thread.raise LDAP::Abandon if thread
end
end
def abandon_all
@mutex.synchronize do
@threadgroup.list.each do |thread|
thread.raise LDAP::Abandon
end
end
end
def send_unsolicited_notification(resultCode, opt={})
protocolOp = [
OpenSSL::ASN1::Enumerated(resultCode),
OpenSSL::ASN1::OctetString(opt[:matchedDN] || ""),
OpenSSL::ASN1::OctetString(opt[:errorMessage] || ""),
]
if opt[:referral]
rs = opt[:referral].collect { |r| OpenSSL::ASN1::OctetString(r) }
protocolOp << OpenSSL::ASN1::Sequence(rs, 3, :IMPLICIT, :APPLICATION)
end
if opt[:responseName]
protocolOp << OpenSSL::ASN1::OctetString(opt[:responseName], 10, :IMPLICIT, :APPLICATION)
end
if opt[:response]
protocolOp << OpenSSL::ASN1::OctetString(opt[:response], 11, :IMPLICIT, :APPLICATION)
end
message = [
OpenSSL::ASN1::Integer(0),
OpenSSL::ASN1::Sequence(protocolOp, 24, :IMPLICIT, :APPLICATION),
]
message << opt[:controls] if opt[:controls]
write(OpenSSL::ASN1::Sequence(message).to_der)
end
def send_notice_of_disconnection(resultCode, errorMessage="")
send_unsolicited_notification(resultCode,
:errorMessage=>errorMessage,
:responseName=>"1.3.6.1.4.1.1466.20036"
)
end
end
end # class Server
end # module LDAP
+220
View File
@@ -0,0 +1,220 @@
require 'ldap/server/util'
module LDAP
class Server
class DN
include Enumerable
attr_reader :dname
# Combines a set of elements to a syntactically correct DN
# elements is [elements, ...] where elements
# can be either { attr => val } or [attr, val]
def self.join(elements)
LDAP::Server::Operation.join_dn(elements)
end
def initialize(dn)
@dname = LDAP::Server::Operation.split_dn(dn)
end
# Returns the value of the first occurrence of attr (bottom-up)
def find_first(attr)
@dname.each do |pair|
return pair[attr.to_s] if pair[attr.to_s]
end
nil
end
# Returns the value of the last occurrence of attr (bottom-up)
def find_last(attr)
@dname.reverse_each do |pair|
return pair[attr.to_s] if pair[attr.to_s]
end
nil
end
# Returns all values of all occurrences of attr (bottom-up)
def find(attr)
result = []
@dname.each do |pair|
result << pair[attr.to_s] if pair[attr.to_s]
end
result
end
# Returns the value of the n-th occurrence of attr (top-down, 0 is first element)
def find_nth(attr, n)
i = 0
@dname.each do |pair|
if pair[attr.to_s]
return pair[attr.to_s] if i == n
i += 1
end
end
nil
end
# Whether or not the DN starts with dn (bottom-up)
# dn is a string
def start_with?(dn)
needle = LDAP::Server::Operation.split_dn(dn)
# Needle is longer than haystack
return false if needle.length > @dname.length
needle_index = 0
haystack_index = 0
while needle_index < needle.length
return false if @dname[haystack_index] != needle[needle_index]
needle_index += 1
haystack_index += 1
end
true
end
# Whether or not the DN starts with a format (bottom-up) (values are ignored)
# dn is a string
def start_with_format?(dn)
needle = LDAP::Server::Operation.split_dn(dn)
# Needle is longer than haystack
return false if needle.length > @dname.length
needle_index = 0
haystack_index = 0
while needle_index < needle.length
return false if @dname[haystack_index].keys != needle[needle_index].keys
needle_index += 1
haystack_index += 1
end
true
end
# Whether or not the DN ends with dn (top-down)
# dn is a string
def end_with?(dn)
needle = LDAP::Server::Operation.split_dn(dn)
# Needle is longer than haystack
return false if needle.length > @dname.length
needle_index = needle.length - 1
haystack_index = @dname.length - 1
while needle_index >= 0
return false if @dname[haystack_index] != needle[needle_index]
needle_index -= 1
haystack_index -= 1
end
true
end
# Whether or not the DN ends with format (top-down) (values are ignored)
# dn is a string
def end_with_format?(dn)
needle = LDAP::Server::Operation.split_dn(dn)
# Needle is longer than haystack
return false if needle.length > @dname.length
needle_index = needle.length - 1
haystack_index = @dname.length - 1
while needle_index >= 0
return false if @dname[haystack_index].keys != needle[needle_index].keys
needle_index -= 1
haystack_index -= 1
end
true
end
# Whether or not the DN equals dn (values are case sensitive)
# dn is a string
def equal?(dn)
split_dn = LDAP::Server::Operation.split_dn(dn)
return false if split_dn.length != @dname.length
@dname.each_with_index do |pair, index|
return false if pair != split_dn[index]
end
true
end
# Whether or not the DN equals dn's format (values are ignored) (case insensitive)
# dn is a string
def equal_format?(dn)
split_dn = LDAP::Server::Operation.split_dn(dn)
return false if split_dn.length != @dname.length
@dname.each_with_index do |pair, index|
return false if pair.keys != split_dn[index].keys
end
true
end
# Whether or not the DN constains a substring equal to dn (values are case sensitive)
# dn is a string
def include?(dn)
split_dn = LDAP::Server::Operation.split_dn(dn)
return false if split_dn.length > @dname.length
LDAP::Server::Operation.join_dn(@dname).include?(LDAP::Server::Operation.join_dn(split_dn))
end
# Whether or not the DN constains a substring format equal to dn (values are ignored) (case insensitive)
# dn is a string
def include_format?(dn)
split_dn = LDAP::Server::Operation.split_dn(dn)
return false if split_dn.length > @dname.length
haystack = []
@dname.each { |pair| haystack << pair.keys }
needle = []
split_dn.each { |pair| needle << pair.keys }
haystack.join.include?(needle.join)
end
# Generates a mapping for variables
# For example:
# > dn = LDAP::Server.DN.new("uid=user,ou=Users,dc=mydomain,dc=com")
# > dn.parse("uid=:uid, ou=:category, dc=mydomain, dc=com")
# => { :uid => "user", :category => "Users" }
def parse(template_dn)
result = {}
template = LDAP::Server::Operation.split_dn(template_dn)
template.reverse.zip(@dname.reverse).each do |temp, const|
break if const and temp.keys.first != const.keys.first
if temp.values.first.start_with?(':')
sym = temp.values.first[1..-1].to_sym
if const
result[sym] = const.values.first unless result[sym]
else
result[sym] = nil
end
elsif temp.values.first != const.values.first
break
end
end
result
end
def each(&block)
@dname.each(&block)
end
def reverse_each(&block)
@dname.reverse_each(&block)
end
end
end
end
+223
View File
@@ -0,0 +1,223 @@
require 'ldap/server/result'
require 'ldap/server/match'
module LDAP
class Server
# LDAP filters are parsed into a LISP-like internal representation:
#
# [:true]
# [:false]
# [:undef]
# [:and, ..., ..., ...]
# [:or, ..., ..., ...]
# [:not, ...]
# [:present, attr]
# [:eq, attr, MO, val]
# [:approx, attr, MO, val]
# [:substrings, attr, MO, initial=nil, {any, any...}, final=nil]
# [:ge, attr, MO, val]
# [:le, attr, MO, val]
#
# This is done rather than a more object-oriented approach, in the
# hope that it will make it easier to match certain filter structures
# when converting them into something else. e.g. certain LDAP filter
# constructs can be mapped to some fixed SQL queries.
#
# See RFC 2251 4.5.1 for the three-state(!) boolean logic from LDAP
#
# If no schema is provided: 'attr' is the raw attribute name as provided
# by the client. If a schema is provided: attr is converted to its
# normalized name as listed in the schema, e.g. 'commonname' becomes 'cn',
# 'objectclass' becomes 'objectClass' etc.
# If a schema is provided, MO is a matching object which can be used to
# perform the match. If no schema is provided, this is 'nil'. In that
# case you could use LDAP::Server::MatchingRule::DefaultMatch.
class Filter
# Parse a filter in OpenSSL::ASN1 format into our own format.
#
# There are some trivial optimisations we make: e.g.
# (&(objectClass=*)(cn=foo)) -> (&(cn=foo)) -> (cn=foo)
def self.parse(asn1, schema=nil)
case asn1.tag
when 0 # and
conds = asn1.value.collect { |a| parse(a) }
conds.delete([:true])
return [:true] if conds.size == 0
return conds.first if conds.size == 1
return [:false] if conds.include?([:false])
return conds.unshift(:and)
when 1 # or
conds = asn1.value.collect { |a| parse(a) }
conds.delete([:false])
return [:false] if conds.size == 0
return conds.first if conds.size == 1
return [:true] if conds.include?([:true])
return conds.unshift(:or)
when 2 # not
cond = parse(asn1.value[0])
case cond
when [:false]; return [:true]
when [:true]; return [:false]
when [:undef]; return [:undef]
end
return [:not, cond]
when 3 # equalityMatch
attr = asn1.value[0].value
val = asn1.value[1].value
return [:true] if attr =~ /\AobjectClass\z/i and val =~ /\Atop\z/i
if schema
a = schema.find_attrtype(attr)
return [:undef] unless a.equality
return [:eq, a.to_s, a.equality, val]
end
return [:eq, attr, nil, val]
when 4 # substrings
attr = asn1.value[0].value
if schema
a = schema.find_attrtype(attr)
return [:undef] unless a.substr
res = [:substrings, a.to_s, a.substr, nil]
else
res = [:substrings, attr, nil, nil]
end
final_val = nil
asn1.value[1].value.each do |ss|
case ss.tag
when 0
res[3] = ss.value
when 1
res << ss.value
when 2
final_val = ss.value
else
raise LDAP::ResultError::ProtocolError,
"Unrecognised substring tag #{ss.tag.inspect}"
end
end
res << final_val
return res
when 5 # greaterOrEqual
attr = asn1.value[0].value
val = asn1.value[1].value
if schema
a = schema.find_attrtype(attr)
return [:undef] unless a.ordering
return [:ge, a.to_s, a.ordering, val]
end
return [:ge, attr, nil, val]
when 6 # lessOrEqual
attr = asn1.value[0].value
val = asn1.value[1].value
if schema
a = schema.find_attrtype(attr)
return [:undef] unless a.ordering
return [:le, a.to_s, a.ordering, val]
end
return [:le, attr, nil, val]
when 7 # present
attr = asn1.value
return [:true] if attr =~ /\AobjectClass\z/i
if schema
begin
a = schema.find_attrtype(attr)
return [:present, a.to_s]
rescue LDAP::ResultError::UndefinedAttributeType
return [:false]
end
end
return [:present, attr]
when 8 # approxMatch
attr = asn1.value[0].value
val = asn1.value[1].value
if schema
a = schema.find_attrtype(attr)
# I don't know how properly to deal with approxMatch. I'm assuming
# that the object will have an equality MatchingRule, and we
# can defer to that.
return [:undef] unless a.equality
return [:approx, a.to_s, a.equality, val]
end
return [:approx, attr, nil, val]
#when 9 # extensibleMatch
# FIXME
else
raise LDAP::ResultError::ProtocolError,
"Unrecognised Filter tag #{asn1.tag}"
end
# Unknown attribute type
rescue LDAP::ResultError::UndefinedAttributeType
return [:undef]
end
# Run a parsed filter against an attr=>[val] hash.
#
# Returns true, false or nil.
def self.run(filter, av)
case filter[0]
when :and
res = true
filter[1..-1].each do |elem|
r = run(elem, av)
return false if r == false
res = nil if r.nil?
end
return res
when :or
res = false
filter[1..-1].each do |elem|
r = run(elem, av)
return true if r == true
res = nil if r.nil?
end
return res
when :not
case run(filter[1], av)
when true; return false
when false; return true
else return nil
end
when :present
return av.has_key?(filter[1])
when :eq, :approx, :le, :ge, :substrings
# the filter now includes a suitable matching object
return (filter[2] || LDAP::Server::MatchingRule::DefaultMatch).send(
filter.first, Array(av[filter[1].to_s]), *filter[3..-1])
when :true
return true
when :false
return false
when :undef
return nil
end
raise LDAP::ResultError::OperationsError,
"Unimplemented filter #{filter.first.inspect}"
end
end # class Filter
end # class Server
end # module LDAP
+283
View File
@@ -0,0 +1,283 @@
require 'ldap/server/syntax'
require 'ldap/server/result'
module LDAP
class Server
# A class which holds LDAP MatchingRules. For now there is a global pool
# of MatchingRule objects (rather than each Schema object having
# its own pool)
class MatchingRule
attr_reader :oid, :names, :syntax, :desc, :obsolete
# Create a new MatchingRule object
def initialize(oid, names, syntax, desc=nil, obsolete=false, &blk)
@oid = oid
@names = names
@names = [@names] unless @names.is_a?(Array)
@desc = desc
@obsolete = obsolete
@syntax = LDAP::Server::Syntax.find(syntax) # creates new obj if reqd
@def = nil
# initialization hook
self.instance_eval(&blk) if blk
end
def name
(@names && names[0]) || @oid
end
def to_s
(@names && names[0]) || @oid
end
def normalize(x)
x
end
# Create a new MatchingRule object, given its description string
def self.from_def(str, &blk)
m = LDAP::Server::Syntax::MatchingRuleDescription.match(str)
raise LDAP::ResultError::InvalidAttributeSyntax,
"Bad MatchingRuleDescription #{str.inspect}" unless m
new(m[1], m[2].scan(/'(.*?)'/).flatten, m[5], m[3], m[4], &blk)
end
def to_def
return @def if @def
ans = "( #{@oid} "
if names.nil? or @names.empty?
# nothing
elsif @names.size == 1
ans << "NAME '#{@names[0]}' "
else
ans << "NAME ( "
@names.each { |n| ans << "'#{n}' " }
ans << ") "
end
ans << "DESC '#@desc' " if @desc
ans << "OBSOLETE " if @obsolete
ans << "SYNTAX #@syntax " if @syntax
ans << ")"
@def = ans
end
@@rules = {} # oid / name / alias => object
# Add a new matching rule
def self.add(*args, &blk)
s = new(*args, &blk)
@@rules[s.oid] = s
return if s.names.nil?
s.names.each do |n|
@@rules[n.downcase] = s
end
end
# Find a MatchingRule object given a name or oid, or return nil
# (? should we create one automatically, like Syntax)
def self.find(x)
return x if x.nil? or x.is_a?(LDAP::Server::MatchingRule)
@@rules[x.downcase]
end
# Return all known matching rules
def self.all_matching_rules
@@rules.values.uniq
end
# Now some things we can mixin to a MatchingRule when needed.
# Replace 'normalize' with a function which gives the canonical
# version of a value for comparison.
module Equality
def eq(vals, m)
return false if vals.nil?
m = normalize(m)
vals.each { |v| return true if normalize(v) == m }
return false
end
end
module Ordering
def ge(vals, m)
return false if vals.nil?
m = normalize(m)
vals.each { |v| return true if normalize(v) >= m }
return false
end
def le(vals, m)
return false if vals.nil?
m = normalize(m)
vals.each { |v| return true if normalize(v) <= m }
return false
end
end
module Substrings
def substrings(vals, *ss)
return false if vals.nil?
# convert the condition list into a regexp
re = []
re << "^#{Regexp.escape(normalize(ss[0]).to_s)}" if ss[0]
ss[1..-2].each { |s| re << Regexp.escape(normalize(s).to_s) }
re << "#{Regexp.escape(normalize(ss[-1]).to_s)}$" if ss[-1]
re = Regexp.new(re.join(".*"))
vals.each do |v|
v = normalize(v).to_s
return true if re.match(v)
end
return false
end
end # module Substrings
class DefaultMatchingClass
include MatchingRule::Equality
include MatchingRule::Ordering
include MatchingRule::Substrings
def normalize(x)
x
end
end
DefaultMatch = DefaultMatchingClass.new
end # class MatchingRule
#
# And now, here are some matching rules you can use (RFC2252 section 8)
#
class MatchingRule
add('2.5.13.0', 'objectIdentifierMatch', '1.3.6.1.4.1.1466.115.121.1.38') do
extend Equality
end
# FIXME: Filters should return undef if the OID is not in the schema
# (which means passing in the schema to every equality test)
add('2.5.13.1', 'distinguishedNameMatch', '1.3.6.1.4.1.1466.115.121.1.12') do
extend Equality
end
# FIXME: Distinguished Name matching is supposed to parse the DN into
# its parts and then apply the schema equality rules to each part
# (i.e. some parts may be case-sensitive, others case-insensitive)
# This is just one of the many nonsense design decisions in LDAP :-(
# How is a DirectoryString different to an IA5String or a PrintableString?
module StringTrim
def normalize(x); x.gsub(/^\s*|\s*$/, '').gsub(/\s+/,' '); end
end
module StringDowncase
def normalize(x); x.downcase.gsub(/^\s*|\s*$/, '').gsub(/\s+/,' '); end
end
add('2.5.13.2', 'caseIgnoreMatch', '1.3.6.1.4.1.1466.115.1') do
extend Equality
extend StringDowncase
end
module Integer
def normalize(x); x.to_i; end
end
add('2.5.13.8', 'numericStringMatch', '1.3.6.1.4.1.1466.115.121.1.36') do
extend Equality
extend Integer
end
# TODO: Add semantics for these (difficult since RFC2252 doesn't give
# them, so we presumably have to go through X.500)
add('2.5.13.11', 'caseIgnoreListMatch', '1.3.6.1.4.1.1466.115.121.1.41')
add('2.5.13.14', 'integerMatch', '1.3.6.1.4.1.1466.115.121.1.27') do
extend Equality
extend Integer
end
add('2.5.13.16', 'bitStringMatch', '1.3.6.1.4.1.1466.115.121.1.6')
add('2.5.13.20', 'telephoneNumberMatch', '1.3.6.1.4.1.1466.115.121.1.50') do
extend Equality
extend StringTrim
end
add('2.5.13.22', 'presentationAddressMatch', '1.3.6.1.4.1.1466.115.121.1.43')
add('2.5.13.23', 'uniqueMemberMatch', '1.3.6.1.4.1.1466.115.121.1.34')
add('2.5.13.24', 'protocolInformationMatch', '1.3.6.1.4.1.1466.115.121.1.42')
add('2.5.13.27', 'generalizedTimeMatch', '1.3.6.1.4.1.1466.115.121.1.24') { extend Equality }
# IA5 stuff. FIXME: What's the correct way to 'downcase' UTF8 strings?
module IA5Trim
def normalize(x); x.gsub(/^\s*|\s*$/u, '').gsub(/\s+/u,' '); end
end
module IA5Downcase
def normalize(x); x.downcase.gsub(/^\s*|\s*$/u, '').gsub(/\s+/u,' '); end
end
add('1.3.6.1.4.1.1466.109.114.1', 'caseExactIA5Match', '1.3.6.1.4.1.1466.115.121.1.26') do
extend Equality
extend IA5Trim
end
add('1.3.6.1.4.1.1466.109.114.2', 'caseIgnoreIA5Match', '1.3.6.1.4.1.1466.115.121.1.26') do
extend Equality
extend IA5Downcase
end
add('2.5.13.28', 'generalizedTimeOrderingMatch', '1.3.6.1.4.1.1466.115.121.1.24') { extend Ordering }
add('2.5.13.3', 'caseIgnoreOrderingMatch', '1.3.6.1.4.1.1466.115.121.1.15') do
extend Ordering
extend StringDowncase
end
add('2.5.13.4', 'caseIgnoreSubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.58') do
extend Substrings
extend StringDowncase
end
add('2.5.13.21', 'telephoneNumberSubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.58') do
extend Substrings
end
add('2.5.13.10', 'numericStringSubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.58') do
extend Substrings
end
# from OpenLDAP
add('1.3.6.1.4.1.4203.1.2.1', 'caseExactIA5SubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.26') do
extend Substrings
extend IA5Trim
end
add('1.3.6.1.4.1.1466.109.114.3', 'caseIgnoreIA5SubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.26') do
extend Substrings
extend IA5Downcase
end
add('2.5.13.5', 'caseExactMatch', '1.3.6.1.4.1.1466.115.121.1.15') { extend Equality }
add('2.5.13.6', 'caseExactOrderingMatch', '1.3.6.1.4.1.1466.115.121.1.15') { extend Ordering }
add('2.5.13.7', 'caseExactSubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.58') { extend Substrings }
add('2.5.13.9', 'numericStringOrderingMatch', '1.3.6.1.4.1.1466.115.121.1.36') { extend Ordering; extend Integer }
add('2.5.13.13', 'booleanMatch', '1.3.6.1.4.1.1466.115.121.1.7') do
extend Equality
def self.normalize(x)
return true if x == 'TRUE'
return false if x == 'FALSE'
x
end
end
add('2.5.13.15', 'integerOrderingMatch', '1.3.6.1.4.1.1466.115.121.1.27') { extend Ordering; extend Integer }
add('2.5.13.17', 'octetStringMatch', '1.3.6.1.4.1.1466.115.121.1.40') { extend Equality }
add('2.5.13.18', 'octetStringOrderingMatch', '1.3.6.1.4.1.1466.115.121.1.40') { extend Ordering }
add('2.5.13.19', 'octetStringSubstringsMatch', '1.3.6.1.4.1.1466.115.121.1.40') { extend Substrings }
end # class MatchingRule
end # class Server
end # module LDAP
+528
View File
@@ -0,0 +1,528 @@
require 'timeout'
require 'openssl'
require 'ldap/server/result'
require 'ldap/server/filter'
module LDAP
class Server
# Scope
BaseObject = 0
SingleLevel = 1
WholeSubtree = 2
# DerefAliases
NeverDerefAliases = 0
DerefInSearching = 1
DerefFindingBaseObj = 2
DerefAlways = 3
# Object to handle a single LDAP request. Typically you would
# subclass this object and override methods 'simple_bind', 'search' etc.
# The do_xxx methods are internal, and handle the parsing of requests
# and the sending of responses.
class Operation
# An instance of this object is created by the Connection object
# for each operation which is requested by the client. If you subclass
# Operation, and you override initialize, make sure you call 'super'.
def initialize(connection, messageID)
@connection = connection
@respEnvelope = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Integer(messageID),
# protocolOp,
# controls [0] OPTIONAL,
])
@schema = @connection.opt[:schema]
@server = @connection.opt[:server]
@attribute_range_limit = @connection.opt[:attribute_range_limit]
end
def log msg, severity = Logger::INFO
@connection.log msg, severity
end
def debug msg
@connection.debug msg
end
# Send an exception report to the log
def log_exception msg
@connection.log_exception msg
end
##################################################
### Utility methods to send protocol responses ###
##################################################
def send_LDAPMessage(protocolOp, opt={}) # :nodoc:
@respEnvelope.value[1] = protocolOp
if opt[:controls]
@respEnvelope.value[2] = OpenSSL::ASN1::Set(opt[:controls], 0, :IMPLICIT, APPLICATION)
else
@respEnvelope.value.delete_at(2)
end
if false # $debug
puts "Response:"
p @respEnvelope
p @respEnvelope.to_der.unpack("H*")
end
@connection.write(@respEnvelope.to_der)
end
def send_LDAPResult(tag, resultCode, opt={}) # :nodoc:
seq = [
OpenSSL::ASN1::Enumerated(resultCode),
OpenSSL::ASN1::OctetString(opt[:matchedDN] || ""),
OpenSSL::ASN1::OctetString(opt[:errorMessage] || ""),
]
if opt[:referral]
rs = opt[:referral].collect { |r| OpenSSL::ASN1::OctetString(r) }
seq << OpenSSL::ASN1::Sequence(rs, 3, :IMPLICIT, :APPLICATION)
end
yield seq if block_given? # opportunity to add more elements
send_LDAPMessage(OpenSSL::ASN1::Sequence(seq, tag, :IMPLICIT, :APPLICATION), opt)
end
def send_BindResponse(resultCode, opt={})
send_LDAPResult(1, resultCode, opt) do |resp|
if opt[:serverSaslCreds]
resp << OpenSSL::ASN1::OctetString(opt[:serverSaslCreds], 7, :IMPLICIT, :APPLICATION)
end
end
end
AttributeRange = Struct.new :start, :end
# Send a found entry. Avs are {attr1=>val1, attr2=>[val2,val3]}
# If schema given, return operational attributes only if
# explicitly requested
def send_SearchResultEntry(dn, avs, opt={})
@rescount += 1
if @sizelimit
raise LDAP::ResultError::SizeLimitExceeded if @rescount > @sizelimit
end
if @schema
# normalize the attribute names
@attributes = @attributes.map { |a| a == '*' ? a : @schema.find_attrtype(a).to_s }
end
sendall = @attributes == [] || @attributes.include?("*")
avseq = []
avs.each_with_index do |(attr, vals), aidx|
query_attr_idx = @attributes.index(attr)
if !query_attr_idx
next unless sendall
if @schema
a = @schema.find_attrtype(attr)
next unless a and (a.usage.nil? or a.usage == :userApplications)
end
end
query_attr = query_attr_idx && @attribute_ranges[query_attr_idx]
if @typesOnly
vals = []
else
vals = [vals] unless vals.kind_of?(Array)
# FIXME: optionally do a value_to_s conversion here?
# FIXME: handle attribute;binary
end
if (@attribute_range_limit && vals.size > @attribute_range_limit) || query_attr&.start
if query_attr&.start
range_start = query_attr.start.to_i
range_end = query_attr.end == "*" ? -1 : query_attr.end.to_i
else
range_start = 0
range_end = @attribute_range_limit ? @attribute_range_limit - 1 : -1
end
range_end = range_start + @attribute_range_limit - 1 if @attribute_range_limit && (vals.size - range_start > @attribute_range_limit)
range_end = -1 if vals.size <= range_end
rvals = vals[range_start .. range_end]
vals = []
avseq << OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::OctetString("#{attr};range=#{range_start}-#{range_end == -1 ? "*" : range_end}"),
OpenSSL::ASN1::Set(rvals.collect { |v| OpenSSL::ASN1::OctetString(v.to_s) })
])
end
avseq << OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::OctetString(attr),
OpenSSL::ASN1::Set(vals.collect { |v| OpenSSL::ASN1::OctetString(v.to_s) })
])
end
send_LDAPMessage(OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::OctetString(dn),
OpenSSL::ASN1::Sequence(avseq),
], 4, :IMPLICIT, :APPLICATION), opt)
end
def send_SearchResultReference(urls, opt={})
send_LDAPMessage(OpenSSL::ASN1::Sequence(
urls.collect { |url| OpenSSL::ASN1::OctetString(url) }
),
opt
)
end
def send_SearchResultDone(resultCode, opt={})
send_LDAPResult(5, resultCode, opt)
end
def send_ModifyResponse(resultCode, opt={})
send_LDAPResult(7, resultCode, opt)
end
def send_AddResponse(resultCode, opt={})
send_LDAPResult(9, resultCode, opt)
end
def send_DelResponse(resultCode, opt={})
send_LDAPResult(11, resultCode, opt)
end
def send_ModifyDNResponse(resultCode, opt={})
send_LDAPResult(13, resultCode, opt)
end
def send_CompareResponse(resultCode, opt={})
send_LDAPResult(15, resultCode, opt)
end
def send_ExtendedResponse(resultCode, opt={})
send_LDAPResult(24, resultCode, opt) do |resp|
if opt[:responseName]
resp << OpenSSL::ASN1::OctetString(opt[:responseName], 10, :IMPLICIT, :APPLICATION)
end
if opt[:response]
resp << OpenSSL::ASN1::OctetString(opt[:response], 11, :IMPLICIT, :APPLICATION)
end
end
end
##########################################
### Methods to parse each request type ###
##########################################
def do_bind(protocolOp, controls) # :nodoc:
version = protocolOp.value[0].value
dn = protocolOp.value[1].value
dn = nil if dn == ""
authentication = protocolOp.value[2]
case authentication.tag # tag_class == :CONTEXT_SPECIFIC (check why)
when 0
simple_bind(version, dn, authentication.value)
when 3
# mechanism = authentication.value[0].value
# credentials = authentication.value[1].value
# sasl_bind(version, dn, mechanism, credentials)
# FIXME: needs to exchange further BindRequests
raise LDAP::ResultError::AuthMethodNotSupported
else
raise LDAP::ResultError::ProtocolError, "BindRequest bad AuthenticationChoice"
end
send_BindResponse(0)
return dn, version
rescue LDAP::ResultError => e
send_BindResponse(e.to_i, :errorMessage=>e.message)
return nil, version
end
# reformat ASN1 into {attr=>[vals], attr=>[vals]}
#
# AttributeList ::= SEQUENCE OF SEQUENCE {
# type AttributeDescription,
# vals SET OF AttributeValue }
def attributelist(set) # :nodoc:
av = {}
set.value.each do |seq|
a = seq.value[0].value
if @schema
a = @schema.find_attrtype(a).to_s
end
v = seq.value[1].value.collect { |asn1| asn1.value }
# Not clear from the spec whether the same attribute (with
# distinct values) can appear more than once in AttributeList
raise LDAP::ResultError::AttributeOrValueExists, a if av[a]
av[a] = v
end
return av
end
def do_search(protocolOp, controls) # :nodoc:
baseObject = protocolOp.value[0].value
scope = protocolOp.value[1].value
deref = protocolOp.value[2].value
client_sizelimit = protocolOp.value[3].value
client_timelimit = protocolOp.value[4].value.to_i
@typesOnly = protocolOp.value[5].value
filter = Filter::parse(protocolOp.value[6], @schema)
attributes = protocolOp.value[7].value.collect {|x| x.value}
attributes = attributes.map do |attr|
if attr =~ /(.*);range=(\d+)-(\d+|\*)\z/
[$1, $2, $3]
else
attr
end
end
@attributes = attributes.map do |name, |
name
end
@attribute_ranges = attributes.map do |_, range_start, range_end|
range_start && AttributeRange.new(range_start, range_end)
end
@rescount = 0
@sizelimit = server_sizelimit
@sizelimit = client_sizelimit if client_sizelimit > 0 and
(@sizelimit.nil? or client_sizelimit < @sizelimit)
if baseObject.empty? and scope == BaseObject
send_SearchResultEntry("", @server.root_dse) if
@server.root_dse and LDAP::Server::Filter.run(filter, @server.root_dse)
send_SearchResultDone(0)
return
elsif @schema and baseObject == @schema.subschema_dn
send_SearchResultEntry(baseObject, @schema.subschema_subentry) if
@schema and @schema.subschema_subentry and
LDAP::Server::Filter.run(filter, @schema.subschema_subentry)
send_SearchResultDone(0)
return
end
t = server_timelimit || 10
t = client_timelimit if client_timelimit > 0 and client_timelimit < t
Timeout::timeout(t, LDAP::ResultError::TimeLimitExceeded) do
search(baseObject, scope, deref, filter)
end
send_SearchResultDone(0)
# Note that TimeLimitExceeded is a subclass of LDAP::ResultError
rescue LDAP::ResultError => e
send_SearchResultDone(e.to_i, :errorMessage=>e.message)
rescue Abandon
# send no response
# Since this Operation is running in its own thread, we have to
# catch all other exceptions. Otherwise, in the event of a programming
# error, this thread will silently terminate and the client will wait
# forever for a response.
rescue Exception => e
log_exception(e)
send_SearchResultDone(LDAP::ResultError::OperationsError.new.to_i, :errorMessage=>e.message)
end
def do_modify(protocolOp, controls) # :nodoc:
dn = protocolOp.value[0].value
modinfo = {}
protocolOp.value[1].value.each do |seq|
attr = seq.value[1].value[0].value
if @schema
attr = @schema.find_attrtype(attr).to_s
end
vals = seq.value[1].value[1].value.collect { |v| v.value }
case seq.value[0].value.to_i
when 0
modinfo[attr] = [:add] + vals
when 1
modinfo[attr] = [:delete] + vals
when 2
modinfo[attr] = [:replace] + vals
else
raise LDAP::ResultError::ProtocolError, "Bad modify operation #{seq.value[0].value}"
end
end
modify(dn, modinfo)
send_ModifyResponse(0)
rescue LDAP::ResultError => e
send_ModifyResponse(e.to_i, :errorMessage=>e.message)
rescue Abandon
# no response
rescue Exception => e
log_exception(e)
send_ModifyResponse(LDAP::ResultCode::OperationsError.new.to_i, :errorMessage=>e.message)
end
def do_add(protocolOp, controls) # :nodoc:
dn = protocolOp.value[0].value
av = attributelist(protocolOp.value[1])
add(dn, av)
send_AddResponse(0)
rescue LDAP::ResultError => e
send_AddResponse(e.to_i, :errorMessage=>e.message)
rescue Abandon
# no response
rescue Exception => e
log_exception(e)
send_AddResponse(LDAP::ResultCode::OperationsError.new.to_i, :errorMessage=>e.message)
end
def do_del(protocolOp, controls) # :nodoc:
dn = protocolOp.value
del(dn)
send_DelResponse(0)
rescue LDAP::ResultError => e
send_DelResponse(e.to_i, :errorMessage=>e.message)
rescue Abandon
# no response
rescue Exception => e
log_exception(e)
send_DelResponse(LDAP::ResultCode::OperationsError.new.to_i, :errorMessage=>e.message)
end
def do_modifydn(protocolOp, controls) # :nodoc:
entry = protocolOp.value[0].value
newrdn = protocolOp.value[1].value
deleteoldrdn = protocolOp.value[2].value
if protocolOp.value.size > 3 and protocolOp.value[3].tag == 0
newSuperior = protocolOp.value[3].value
end
modifydn(entry, newrdn, deleteoldrdn, newSuperior)
send_ModifyDNResponse(0)
rescue LDAP::ResultError => e
send_ModifyDNResponse(e.to_i, :errorMessage=>e.message)
rescue Abandon
# no response
rescue Exception => e
log_exception(e)
send_ModifyDNResponse(LDAP::ResultCode::OperationsError.new.to_i, :errorMessage=>e.message)
end
def do_compare(protocolOp, controls) # :nodoc:
entry = protocolOp.value[0].value
ava = protocolOp.value[1].value
attr = ava[0].value
if @schema
attr = @schema.find_attrtype(attr).to_s
end
val = ava[1].value
if compare(entry, attr, val)
send_CompareResponse(6) # compareTrue
else
send_CompareResponse(5) # compareFalse
end
rescue LDAP::ResultError => e
send_CompareResponse(e.to_i, :errorMessage=>e.message)
rescue Abandon
# no response
rescue Exception => e
log_exception(e)
send_CompareResponse(LDAP::ResultCode::OperationsError.new.to_i, :errorMessage=>e.message)
end
############################################################
### Methods to get parameters related to this connection ###
############################################################
# Server-set maximum time limit. Override for more complex behaviour
# (e.g. limit depends on @connection.binddn). Nil uses hardcoded default.
def server_timelimit
@connection.opt[:timelimit]
end
# Server-set maximum size limit. Override for more complex behaviour
# (e.g. limit depends on @connection.binddn). Return nil for unlimited.
def server_sizelimit
@connection.opt[:sizelimit]
end
######################################################
### Methods to actually perform the work requested ###
######################################################
# Handle a simple bind request; raise an exception if the bind is
# not acceptable, otherwise just return to accept the bind.
#
# Override this method in your own subclass.
def simple_bind(version, dn, password)
if version != 3
raise LDAP::ResultError::ProtocolError, "version 3 only"
end
if dn
raise LDAP::ResultError::InappropriateAuthentication, "This server only supports anonymous bind"
end
end
# Handle a search request; override this.
#
# Call send_SearchResultEntry for each result found. Raise an exception
# if there is a problem. timeLimit, sizeLimit and typesOnly are taken
# care of, but you need to perform all authorisation checks yourself,
# using @connection.binddn
def search(basedn, scope, deref, filter)
debug "search(#{basedn}, #{scope}, #{deref}, #{filter})"
raise LDAP::ResultError::UnwillingToPerform, "search not implemented"
end
# Handle a modify request; override this
#
# dn is the object to modify; modification is a hash of
# attr => [:add, val, val...] -- add operation
# attr => [:replace, val, val...] -- replace operation
# attr => [:delete, val, val...] -- delete these values
# attr => [:delete] -- delete all values
def modify(dn, modification)
raise LDAP::ResultError::UnwillingToPerform, "modify not implemented"
end
# Handle an add request; override this
#
# Parameters are the dn of the entry to add, and a hash of
# attr=>[val...]
# Raise an exception if there is a problem; it is up to you to check
# that the connection has sufficient authorisation using @connection.binddn
def add(dn, av)
raise LDAP::ResultError::UnwillingToPerform, "add not implemented"
end
# Handle a del request; override this
def del(dn)
raise LDAP::ResultError::UnwillingToPerform, "delete not implemented"
end
# Handle a modifydn request; override this
def modifydn(entry, newrdn, deleteoldrdn, newSuperior)
raise LDAP::ResultError::UnwillingToPerform, "modifydn not implemented"
end
# Handle a compare request; override this. Return true or false,
# or raise an exception for errors.
def compare(entry, attr, val)
raise LDAP::ResultError::UnwillingToPerform, "compare not implemented"
end
end # class Operation
end # class Server
end # module LDAP
+92
View File
@@ -0,0 +1,92 @@
require 'prefork' # <http://raa.ruby-lang.org/project/prefork/>
require 'socket'
module LDAP
class Server
# Accept connections on a port, and for each one run the given block
# in one of N pre-forked children. Returns a Thread object for the
# listener.
#
# Options:
# :port=>port number [required]
# :bindaddr=>"IP address"
# :user=>"username" - drop privileges after bind
# :group=>"groupname" - ditto
# :logger=>object - implements << method
# :listen=>number - listen queue depth
# :nodelay=>true - set TCP_NODELAY option
# :min_servers=>N - prefork parameters
# :max_servers=>N
# :max_requests_per_child=>N
# :max_idle=>N - seconds
def self.preforkserver(opt, &blk)
server = PreFork.new(opt[:bindaddr] || "0.0.0.0", opt[:port])
# Drop privileges if requested
if opt[:group] or opt[:user]
require 'etc'
gid = Etc.getgrnam(opt[:group]).gid if opt[:group]
uid = Etc.getpwnam(opt[:user]).uid if opt[:user]
File.chown(uid, gid, server.instance_eval {@lockf})
Process.gid = Process.egid = gid if gid
Process.uid = Process.euid = uid if uid
end
# Typically the O/S will buffer response data for 100ms before sending.
# If the response is sent as a single write() then there's no need for it.
if opt[:nodelay]
begin
server.sock.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
rescue Exception
end
end
# set queue size for incoming connections (default is 5)
server.sock.listen(opt[:listen]) if opt[:listen]
# Set prefork server parameters
server.min_servers = opt[:min_servers] if opt[:min_servers]
server.max_servers = opt[:max_servers] if opt[:max_servers]
server.max_request_per_child = opt[:max_request_per_child] if opt[:max_request_per_child]
server.max_idle = opt[:max_idle] if opt[:max_idle]
Thread.new do
server.start do |s|
begin
s.instance_eval(&blk)
rescue Interrupt
# This exception can be raised to shut the server down
server.stop
rescue Exception => e
opt[:logger].error(s.peeraddr[3]) { "#{e}: #{e.backtrace[0]}" }
ensure
s.close
end
end
end
end
end # class Server
end # module LDAP
if __FILE__ == $0
# simple test
puts "Running a test POP3 server on port 1110"
t = LDAP::Server.preforkserver(:port=>1110) do
print "+OK I am a fake POP3 server (pid #{$$})\r\n"
while line = gets
case line
when /^quit/i
break
when /^crash/i
raise Errno::EPERM, "dammit!"
else
print "-ERR I don't understand #{line}"
end
end
print "+OK bye\r\n"
end
#sleep 10; t.raise Interrupt # uncomment to run for fixed time period
t.join
end
+166
View File
@@ -0,0 +1,166 @@
require 'openssl'
module LDAP
class Server
class Request
attr_accessor :connection, :typesOnly, :attributes, :rescount, :sizelimit
# Object to handle a single LDAP request. This object is created on
# every request by the router, and is passed as argument to the defined
# routes.
def initialize(connection, messageId)
@connection = connection
@respEnvelope = OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::Integer(messageId),
# protocolOp,
# controls [0] OPTIONAL,
])
@schema = @connection.opt[:schema]
@server = @connection.opt[:server]
@rescount = 0
end
##################################################
### Utility methods to send protocol responses ###
##################################################
def send_LDAPMessage(protocolOp, opt={}) # :nodoc:
@respEnvelope.value[1] = protocolOp
if opt[:controls]
@respEnvelope.value[2] = OpenSSL::ASN1::Set(opt[:controls], 0, :IMPLICIT, APPLICATION)
else
@respEnvelope.value.delete_at(2)
end
@connection.write(@respEnvelope.to_der)
end
def send_LDAPResult(tag, resultCode, opt={}) # :nodoc:
seq = [
OpenSSL::ASN1::Enumerated(resultCode),
OpenSSL::ASN1::OctetString(opt[:matchedDN] || ""),
OpenSSL::ASN1::OctetString(opt[:errorMessage] || ""),
]
if opt[:referral]
rs = opt[:referral].collect { |r| OpenSSL::ASN1::OctetString(r) }
seq << OpenSSL::ASN1::Sequence(rs, 3, :IMPLICIT, :APPLICATION)
end
yield seq if block_given? # opportunity to add more elements
send_LDAPMessage(OpenSSL::ASN1::Sequence(seq, tag, :IMPLICIT, :APPLICATION), opt)
end
def send_BindResponse(resultCode, opt={})
send_LDAPResult(1, resultCode, opt) do |resp|
if opt[:serverSaslCreds]
resp << OpenSSL::ASN1::OctetString(opt[:serverSaslCreds], 7, :IMPLICIT, :APPLICATION)
end
end
end
# Send a found entry. Avs are {attr1=>val1, attr2=>[val2,val3]}
# If schema given, return operational attributes only if
# explicitly requested
def send_SearchResultEntry(dn, avs, opt={})
@rescount += 1
if @sizelimit
raise LDAP::ResultError::SizeLimitExceeded if @rescount > @sizelimit
end
if @schema
# normalize the attribute names
@attributes = @attributes.map { |a| a == '*' ? a : @schema.find_attrtype(a).to_s }
end
sendall = @attributes == [] || @attributes.include?("*")
avseq = []
avs.each do |attr, vals|
if !@attributes.include?(attr)
next unless sendall
if @schema
a = @schema.find_attrtype(attr)
next unless a and (a.usage.nil? or a.usage == :userApplications)
end
end
if @typesOnly
vals = []
else
vals = [vals] unless vals.kind_of?(Array)
# FIXME: optionally do a value_to_s conversion here?
# FIXME: handle attribute;binary
end
avseq << OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::OctetString(attr),
OpenSSL::ASN1::Set(vals.collect { |v| OpenSSL::ASN1::OctetString(v.to_s) })
])
end
send_LDAPMessage(OpenSSL::ASN1::Sequence([
OpenSSL::ASN1::OctetString(dn),
OpenSSL::ASN1::Sequence(avseq),
], 4, :IMPLICIT, :APPLICATION), opt)
end
def send_SearchResultDone(resultCode, opt={})
send_LDAPResult(5, resultCode, opt)
end
def send_ModifyResponse(resultCode, opt={})
send_LDAPResult(7, resultCode, opt)
end
def send_AddResponse(resultCode, opt={})
send_LDAPResult(9, resultCode, opt)
end
def send_DelResponse(resultCode, opt={})
send_LDAPResult(11, resultCode, opt)
end
def send_ModifyDNResponse(resultCode, opt={})
send_LDAPResult(13, resultCode, opt)
end
def send_CompareResponse(resultCode, opt={})
send_LDAPResult(15, resultCode, opt)
end
def send_ExtendedResponse(resultCode, opt={})
send_LDAPResult(24, resultCode, opt) do |resp|
if opt[:responseName]
resp << OpenSSL::ASN1::OctetString(opt[:responseName], 10, :IMPLICIT, :APPLICATION)
end
if opt[:response]
resp << OpenSSL::ASN1::OctetString(opt[:response], 11, :IMPLICIT, :APPLICATION)
end
end
end
############################################################
### Methods to get parameters related to this connection ###
############################################################
# Server-set maximum time limit. Override for more complex behaviour
# (e.g. limit depends on @connection.binddn). Nil uses hardcoded default.
def server_timelimit
@connection.opt[:timelimit]
end
# Server-set maximum size limit. Override for more complex behaviour
# (e.g. limit depends on @connection.binddn). Return nil for unlimited.
def server_sizelimit
@connection.opt[:sizelimit]
end
end
end
end
+71
View File
@@ -0,0 +1,71 @@
module LDAP
# compatible with ruby-ldap
class Error < StandardError
end
class ResultError < Error
end
# This exception is raised when we need to kill an existing Operation
# thread because of a received abandonRequest or bindRequest
class Abandon < Interrupt
end
# ResultError constants from RFC 2251 4.1.10; these are all exceptions
# which can be raised
class ResultError
class Success < self; def to_i; 0; end; end
class OperationsError < self; def to_i; 1; end; end
class ProtocolError < self; def to_i; 2; end; end
class TimeLimitExceeded < self; def to_i; 3; end; end
class SizeLimitExceeded < self; def to_i; 4; end; end
class CompareFalse < self; def to_i; 5; end; end
class CompareTrue < self; def to_i; 6; end; end
class AuthMethodNotSupported < self; def to_i; 7; end; end
class StrongAuthRequired < self; def to_i; 8; end; end
class Referral < self; def to_i; 10; end; end
class AdminLimitExceeded < self; def to_i; 11; end; end
class UnavailableCriticalExtension < self; def to_i; 12; end; end
class ConfidentialityRequired < self; def to_i; 13; end; end
class SaslBindInProgress < self; def to_i; 14; end; end
class NoSuchAttribute < self; def to_i; 16; end; end
class UndefinedAttributeType < self; def to_i; 17; end; end
class InappropriateMatching < self; def to_i; 18; end; end
class ConstraintViolation < self; def to_i; 19; end; end
class AttributeOrValueExists < self; def to_i; 20; end; end
class InvalidAttributeSyntax < self; def to_i; 21; end; end
class NoSuchObject < self; def to_i; 32; end; end
class AliasProblem < self; def to_i; 33; end; end
class InvalidDNSyntax < self; def to_i; 34; end; end
class IsLeaf < self; def to_i; 35; end; end
class AliasDereferencingProblem < self; def to_i; 36; end; end
class InappropriateAuthentication < self; def to_i; 48; end; end
class InvalidCredentials < self; def to_i; 49; end; end
class InsufficientAccessRights < self; def to_i; 50; end; end
class Busy < self; def to_i; 51; end; end
class Unavailable < self; def to_i; 52; end; end
class UnwillingToPerform < self; def to_i; 53; end; end
class LoopDetect < self; def to_i; 54; end; end
class NamingViolation < self; def to_i; 64; end; end
class ObjectClassViolation < self; def to_i; 65; end; end
class NotAllowedOnNonLeaf < self; def to_i; 66; end; end
class NotAllowedOnRDN < self; def to_i; 67; end; end
class EntryAlreadyExists < self; def to_i; 68; end; end
class ObjectClassModsProhibited < self; def to_i; 69; end; end
class AffectsMultipleDSAs < self; def to_i; 71; end; end
class Other < self; def to_i; 80; end; end
# Reverse lookup: so you can do raise LDAP::ResultError[53]
N_TO_CLASS = {
53 => UnwillingToPerform,
# FIXME: please fill in the rest
}
def self.[](n)
return N_TO_CLASS[n] || self
end
end # class ResultError
end # module LDAP
+220
View File
@@ -0,0 +1,220 @@
require 'ldap/server/dn'
require 'ldap/server/util'
require 'ldap/server/trie'
require 'ldap/server/request'
require 'ldap/server/filter'
module LDAP
class Server
class Router
@logger
@routes
# Scope
BaseObject = 0
SingleLevel = 1
WholeSubtree = 2
# DerefAliases
NeverDerefAliases = 0
DerefInSearching = 1
DerefFindingBaseObj = 2
DerefAlways = 3
def initialize(logger, &block)
@logger = logger
@routes = Hash.new
@routes = Trie.new do |trie|
# Add an artificial LDAP component
trie << "op=bind"
trie << "op=search"
end
self.instance_eval(&block)
end
def log_exception(e, level = :error)
@logger.send level, e.message
e.backtrace.each { |line| @logger.send level, "\tfrom#{line}" } if e.backtrace
end
######################
### Initialization ###
######################
def route(operation, hash)
hash.each do |key, value|
if key.nil?
@routes.insert "op=#{operation.to_s}", value
@logger.debug "map operation #{operation.to_s} all routes to #{value}"
else
@routes.insert "#{key},op=#{operation.to_s}", value
@logger.debug "map #{operation.to_s} #{key} to #{value}"
end
end
end
def method_missing(name, *args, &block)
if [:bind, :search, :add, :modify, :modifydn, :del, :compare].include? name
send :route, name, *args
else
super
end
end
####################################################
### Methods to parse and route each request type ###
####################################################
def parse_route(dn, method)
route, action = @routes.match("#{dn},op=#{method.to_s}")
if not route or route.empty?
@logger.warn "No route defined for \'#{route}\'"
raise LDAP::ResultError::UnwillingToPerform
end
if action.nil?
@logger.error "No action defined for route \'#{route}\'"
raise LDAP::ResultError::UnwillingToPerform
end
class_name = action.split('#').first
method_name = action.split('#').last
params = LDAP::Server::DN.new("#{dn},op=#{method.to_s}").parse(route)
return class_name, method_name, params
end
def do_bind(connection, messageId, protocolOp, controls) # :nodoc:
request = Request.new(connection, messageId)
version = protocolOp.value[0].value
dn = protocolOp.value[1].value
dn = nil if dn.empty?
authentication = protocolOp.value[2]
@logger.debug "subject:#{connection.binddn} predicate:bind object:#{dn}"
# Find a route in the routing tree
class_name, method_name, params = parse_route(dn, :bind)
case authentication.tag # tag_class == :CONTEXT_SPECIFIC (check why)
when 0
Object.const_get(class_name).send method_name, request, version, dn, authentication.value, params
when 3
mechanism = authentication.value[0].value
credentials = authentication.value[1].value
# sasl_bind(version, dn, mechanism, credentials)
# FIXME: needs to exchange further BindRequests
# route_sasl_bind(request, version, dn, mechanism, credentials)
raise LDAP::ResultError::AuthMethodNotSupported
else
raise LDAP::ResultError::ProtocolError, "BindRequest bad AuthenticationChoice"
end
request.send_BindResponse(0)
return dn, version
rescue NoMethodError => e
log_exception e
request.send_BindResponse(LDAP::ResultError::OperationsError.new.to_i, :errorMessage => e.message)
return nil, version
rescue LDAP::ResultError => e
log_exception e
request.send_BindResponse(e.to_i, :errorMessage => e.message)
return nil, version
end
def do_search(connection, messageId, protocolOp, controls) # :nodoc:
request = Request.new(connection, messageId)
server = connection.opt[:server]
schema = connection.opt[:schema]
baseObject = protocolOp.value[0].value
scope = protocolOp.value[1].value
deref = protocolOp.value[2].value
client_sizelimit = protocolOp.value[3].value
client_timelimit = protocolOp.value[4].value.to_i
request.typesOnly = protocolOp.value[5].value
filter = LDAP::Server::Filter::parse(protocolOp.value[6], schema)
request.attributes = protocolOp.value[7].value.collect {|x| x.value}
sizelimit = request.server_sizelimit
sizelimit = client_sizelimit if client_sizelimit > 0 and
(sizelimit.nil? or client_sizelimit < sizelimit)
request.sizelimit = sizelimit
if baseObject.empty? and scope == BaseObject
request.send_SearchResultEntry("", server.root_dse) if
server.root_dse and LDAP::Server::Filter.run(filter, server.root_dse)
request.send_SearchResultDone(0)
return
elsif schema and baseObject == schema.subschema_dn
request.send_SearchResultEntry(baseObject, schema.subschema_subentry) if
schema and schema.subschema_subentry and
LDAP::Server::Filter.run(filter, schema.subschema_subentry)
request.send_SearchResultDone(0)
return
end
t = request.server_timelimit || 10
t = client_timelimit if client_timelimit > 0 and client_timelimit < t
@logger.debug "subject:#{connection.binddn} predicate:search object:#{baseObject}"
# Find a route in the routing tree
class_name, method_name, params = parse_route(baseObject, :search)
Timeout::timeout(t, LDAP::ResultError::TimeLimitExceeded) do
Object.const_get(class_name).send method_name, request, baseObject, scope, deref, filter, params
end
request.send_SearchResultDone(0)
# Note that TimeLimitExceeded is a subclass of LDAP::ResultError
rescue LDAP::ResultError => e
request.send_SearchResultDone(e.to_i, :errorMessage=>e.message)
rescue Abandon
# send no response
# Since this Operation is running in its own thread, we have to
# catch all other exceptions. Otherwise, in the event of a programming
# error, this thread will silently terminate and the client will wait
# forever for a response.
rescue Exception => e
log_exception e
request.send_SearchResultDone(LDAP::ResultError::OperationsError.new.to_i, :errorMessage=>e.message)
end
###########################################################
### Methods to actually perform the work requested ###
### Use the signatures below to write your own handlers ###
###########################################################
# Handle a simple bind request; raise an exception if the bind is
# not acceptable, otherwise just return to accept the bind.
#
# Write your own class method using this signature
# def simple_bind(request, version, dn, password, params)
# if version != 3
# raise LDAP::ResultError::ProtocolError, "version 3 only"
# end
# if dn
# raise LDAP::ResultError::InappropriateAuthentication, "This server only supports anonymous bind"
# end
# end
# Handle a search request
#
# Call request. send_SearchResultEntry for each result found. Raise
# an exception if there is a problem. timeLimit, sizeLimit and
# typesOnly are taken care of, but you need to perform all
# authorisation checks yourself, using @connection.binddn
# def search(basedn, scope, deref, filter)
# debug "search(#{basedn}, #{scope}, #{deref}, #{filter})"
# raise LDAP::ResultError::UnwillingToPerform, "search not implemented"
# end
end
end
end
+592
View File
@@ -0,0 +1,592 @@
require 'ldap/server/syntax'
require 'ldap/server/result'
module LDAP
class Server
# This object represents an LDAP schema: that is, a collection of
# objectclasses and attributetypes. Methods are provided for loading
# the schema (from a string or a disk file), and validating an av-hash
# against it.
class Schema
SUBSCHEMA_ENTRY_ATTR = 'cn'
SUBSCHEMA_ENTRY_VALUE = 'Subschema'
def initialize
@attrtypes = {} # name/alias/oid => AttributeType instance
@objectclasses = {} # name/alias/oid => ObjectClass instance
@subschema_cache = nil
end
# return the DN of the subschema subentry
def subschema_dn
"#{SUBSCHEMA_ENTRY_ATTR}=#{SUBSCHEMA_ENTRY_VALUE}"
end
# Return an av hash object giving the subschema subentry. This is cached, so
# call Schema#changed if it needs to be rebuilt
def subschema_subentry
@subschema_cache ||= {
'objectClass' => ['top','subschema','extensibleObject'],
SUBSCHEMA_ENTRY_ATTR => [SUBSCHEMA_ENTRY_VALUE],
'objectClasses' => all_objectclasses.collect { |s| s.to_def },
'attributeTypes' => all_attrtypes.collect { |s| s.to_def },
'ldapSyntaxes' => LDAP::Server::Syntax.all_syntaxes.collect { |s| s.to_def },
#'matchingRules' =>
#'matchingRuleUse' =>
}
end
# Clear the subschema subentry cache, so the next time someone requests
# it, it will be rebuilt
def changed
@subschema_cache = nil
end
# Add an AttributeType to the schema
def add_attrtype(str)
a = AttributeType.new(str)
@attrtypes[a.oid] = a if a.oid
a.names.each do |n|
@attrtypes[n.downcase] = a
end
end
# Locate an attributetype object by name/alias/oid (or raise exception)
def find_attrtype(n)
return n if n.nil? or n.is_a?(LDAP::Server::Schema::AttributeType)
r = @attrtypes[n.downcase]
raise LDAP::ResultError::UndefinedAttributeType, "Unknown AttributeType #{n.inspect}" unless r
r
end
# Return array of all AttributeType objects in this schema
def all_attrtypes
@attrtypes.values.uniq
end
# Add an ObjectClass to the schema
def add_objectclass(str)
o = ObjectClass.new(str)
@objectclasses[o.oid] = o if o.oid
o.names.each do |n|
@objectclasses[n.downcase] = o
end
end
# Locate an objectclass object by name/alias/oid (or raise exception)
def find_objectclass(n)
return n if n.nil? or n.is_a?(LDAP::Server::Schema::ObjectClass)
r = @objectclasses[n.downcase]
raise LDAP::ResultError::ObjectClassViolation, "Unknown ObjectClass #{n.inspect}" unless r
r
end
# Return array of all ObjectClass objects in this schema
def all_objectclasses
@objectclasses.values.uniq
end
# Load an OpenLDAP-format schema from a named file (see notes under 'load')
def load_file(filename)
File.open(filename) { |f| load(f) }
end
# Load an OpenLDAP-format schema from a string or IO object (anything
# which responds to 'each_line'). Lines starting 'attributetype'
# or 'objectclass' contain one of those objects. Does not implement
# named objectIdentifier prefixes (used in the dyngroup.schema file
# supplied with openldap, but not documented in RFC2252)
#
# Note: RFC2252 is strict about the order in which the elements appear,
# and so are we, but OpenLDAP is not. This means that a schema which
# works in OpenLDAP might not load here. For example, RFC2252 says
# that in an objectclass description, "SUP" must come before "MAY";
# if they are the other way round, our regexp-based parser will not
# accept it. The solution is simply to modify the definition so that
# the elements appear in the correct order.
def load(str_or_io)
meth = :junk_line
data = ""
str_or_io.each_line do |line|
case line
when /^\s*#/, /^\s*$/
next
when /^objectclass\s*(.*)$/i
m = $~
send(meth, data)
meth, data = :add_objectclass, m[1]
when /^attributetype\s*(.*)$/i
m = $~
send(meth, data)
meth, data = :add_attrtype, m[1]
else
data << line
end
end
send(meth,data)
self
end
def junk_line(data)
return if data.empty?
raise LDAP::ResultError::InvalidAttributeSyntax,
"Expected 'attributetype' or 'objectclass', got #{data}"
end
private :junk_line
# Load in the base set of objectclasses and attributetypes, being
# the same set as OpenLDAP preloads internally. Includes objectclasses
# 'top', 'objectclass'; attributetypes 'objectclass' , 'cn',
# 'userPassword' and 'distinguishedName'; common operational attributes
# such as 'modifyTimestamp'; plus extras needed for publishing a v3
# schema via LDAP
def load_system
load(<<EOS)
attributetype ( 1.3.6.1.4.1.250.1.57 NAME 'labeledURI' DESC 'RFC2079: Uniform Resource Identifier with optional label' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )
attributetype ( 2.5.4.35 NAME 'userPassword' DESC 'RFC2256/2307: password of user' EQUALITY octetStringMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.40{128} )
attributetype ( 2.5.4.3 NAME ( 'cn' 'commonName' ) DESC 'RFC2256: common name(s) for which the entity is known by' SUP name )
attributetype ( 2.5.4.41 NAME 'name' DESC 'RFC2256: common supertype of name attributes' EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
attributetype ( 2.5.4.49 NAME 'distinguishedName' DESC 'RFC2256: common supertype of DN attributes' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )
attributetype ( 2.16.840.1.113730.3.1.34 NAME 'ref' DESC 'namedref: subordinate referral URL' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 USAGE distributedOperation )
attributetype ( 2.5.4.1 NAME ( 'aliasedObjectName' 'aliasedEntryName' ) DESC 'RFC2256: name of aliased object' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE )
attributetype ( 1.3.6.1.4.1.1466.101.120.16 NAME 'ldapSyntaxes' DESC 'RFC2252: LDAP syntaxes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.54 USAGE directoryOperation )
attributetype ( 2.5.21.8 NAME 'matchingRuleUse' DESC 'RFC2252: matching rule uses' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.31 USAGE directoryOperation )
attributetype ( 2.5.21.6 NAME 'objectClasses' DESC 'RFC2252: object classes' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.37 USAGE directoryOperation )
attributetype ( 2.5.21.5 NAME 'attributeTypes' DESC 'RFC2252: attribute types' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.3 USAGE directoryOperation )
attributetype ( 2.5.21.4 NAME 'matchingRules' DESC 'RFC2252: matching rules' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.30 USAGE directoryOperation )
attributetype ( 1.3.6.1.1.5 NAME 'vendorVersion' DESC 'RFC3045: version of implementation' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation )
attributetype ( 1.3.6.1.1.4 NAME 'vendorName' DESC 'RFC3045: name of implementation vendor' EQUALITY caseExactMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 SINGLE-VALUE NO-USER-MODIFICATION USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.4203.1.3.5 NAME 'supportedFeatures' DESC 'features supported by the server' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.1466.101.120.14 NAME 'supportedSASLMechanisms' DESC 'RFC2252: supported SASL mechanisms' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.1466.101.120.15 NAME 'supportedLDAPVersion' DESC 'RFC2252: supported LDAP versions' SYNTAX 1.3.6.1.4.1.1466.115.121.1.27 USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.1466.101.120.7 NAME 'supportedExtension' DESC 'RFC2252: supported extended operations' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.1466.101.120.13 NAME 'supportedControl' DESC 'RFC2252: supported controls' SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.1466.101.120.5 NAME 'namingContexts' DESC 'RFC2252: naming contexts' SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 USAGE dSAOperation )
attributetype ( 1.3.6.1.4.1.1466.101.120.6 NAME 'altServer' DESC 'RFC2252: alternative servers' SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 USAGE dSAOperation )
attributetype ( 2.5.18.10 NAME 'subschemaSubentry' DESC 'RFC2252: name of controlling subschema entry' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.18.9 NAME 'hasSubordinates' DESC 'X.501: entry has children' EQUALITY booleanMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.7 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.18.4 NAME 'modifiersName' DESC 'RFC2252: name of last modifier' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.18.3 NAME 'creatorsName' DESC 'RFC2252: name of creator' EQUALITY distinguishedNameMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.18.2 NAME 'modifyTimestamp' DESC 'RFC2252: time which object was last modified' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.18.1 NAME 'createTimestamp' DESC 'RFC2252: time which object was created' EQUALITY generalizedTimeMatch ORDERING generalizedTimeOrderingMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.24 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.21.9 NAME 'structuralObjectClass' DESC 'X.500(93): structural object class of entry' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 SINGLE-VALUE NO-USER-MODIFICATION USAGE directoryOperation )
attributetype ( 2.5.4.0 NAME 'objectClass' DESC 'RFC2256: object classes of the entity' EQUALITY objectIdentifierMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )
# These ones aren't published by OpenLDAP, but are referenced by the 'subschema' objectclass
attributetype ( 2.5.21.1 NAME 'dITStructureRules' EQUALITY integerFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.17 USAGE directoryOperation )
attributetype ( 2.5.21.7 NAME 'nameForms' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.35 USAGE directoryOperation )
attributetype ( 2.5.21.2 NAME 'dITContentRules' EQUALITY objectIdentifierFirstComponentMatch SYNTAX 1.3.6.1.4.1.1466.115.121.1.16 USAGE directoryOperation )
objectclass ( 2.5.20.1 NAME 'subschema' DESC 'RFC2252: controlling subschema (sub)entry' AUXILIARY MAY ( dITStructureRules $ nameForms $ ditContentRules $ objectClasses $ attributeTypes $ matchingRules $ matchingRuleUse ) )
#Don't have definition for subtreeSpecification:
#objectClass ( 2.5.17.0 NAME 'subentry' SUP top STRUCTURAL MUST ( cn $ subtreeSpecification ) )
objectClass ( 1.3.6.1.4.1.4203.1.4.1 NAME ( 'OpenLDAProotDSE' 'LDAProotDSE' ) DESC 'OpenLDAP Root DSE object' SUP top STRUCTURAL MAY cn )
objectClass ( 2.16.840.1.113730.3.2.6 NAME 'referral' DESC 'namedref: named subordinate referral' SUP top STRUCTURAL MUST ref )
objectClass ( 2.5.6.1 NAME 'alias' DESC 'RFC2256: an alias' SUP top STRUCTURAL MUST aliasedObjectName )
objectClass ( 1.3.6.1.4.1.1466.101.120.111 NAME 'extensibleObject' DESC 'RFC2252: extensible object' SUP top AUXILIARY )
objectClass ( 2.5.6.0 NAME 'top' DESC 'top of the superclass chain' ABSTRACT MUST objectClass )
EOS
end
# After loading object classes and attr types: resolve oid strings to point
# to objects. This will expose schema inconsistencies (e.g. objectclass
# has unknown SUP class or points to unknown attributeType). However,
# unknown Syntaxes just create new Syntax objects.
def resolve_oids
all_attrtypes.each do |a|
if a.sup
s = find_attrtype(a.sup)
a.instance_eval {
@sup = s
# inherit properties (FIXME: This breaks to_def)
@equality ||= s.equality
@ordering ||= s.ordering
@substr ||= s.substr
@syntax ||= s.syntax
@maxlen ||= s.maxlen
@singlevalue ||= s.singlevalue
@collective ||= s.collective
@nousermod ||= s.nousermod
@usage ||= s.usage
}
end
a.instance_eval do
@syntax = LDAP::Server::Syntax.find(@syntax) if @syntax
@equality = LDAP::Server::MatchingRule.find(@equality) if @equality
@ordering = LDAP::Server::MatchingRule.find(@ordering) if @ordering
@substr = LDAP::Server::MatchingRule.find(@substr) if @substr
end
end
all_objectclasses.each do |o|
if o.sup
s = o.sup.collect { |ss| find_objectclass(ss) }
o.instance_eval { @sup = s }
end
if o.must
s = o.must.collect { |ss| find_attrtype(ss) }
o.instance_eval { @must = s }
end
if o.may
s = o.may.collect { |ss| find_attrtype(ss) }
o.instance_eval { @may = s }
end
end
end
# Validate a new entry or update. For a new entry, just pass a hash
# of attr=>[val, val, ...]; for an update, the first parameter is
# a hash of attr=>[:modtype, val, val...] and the second parameter
# is the existing entry, where it is assumed that the attribute names
# are already in their standard string forms (as returned by attr#name)
#
# Returns a hash containing the updated entry.
#
# If a block is given, it is called to decide whether the user is
# allowed to update an attribute; parameter is the attr *object*
# (not name; use #name if you need its name instead). Return false
# if the update is not permitted. Otherwise, the only restriction
# will be that updates to attributes declared 'nousermod' are forbidden.
#
# No DN checks are done here, since we don't know the DN.
# Checking that the entry contains an attribute for the RDN is the
# responsibility of the caller.
def validate(mods, entry={})
# Run through the mods, make the normalized names, and perform any
# updates
# FIXME: I don't know if these are the right results to return
# for the various types of validation errors
oc_changed = false
res = entry.dup
mods.each do |attrname, nv|
attr = find_attrtype(attrname)
attrname = attr.to_s
raise LDAP::ResultError::ConstraintViolation,
"Cannot modify #{attrname}" if attr.nousermod or
(block_given? and !yield(attr))
# Perform the update
vals = res[attrname] || []
checkvals = []
nv = [nv] unless nv.is_a?(Array)
case nv.first
when :add
checkvals = nv[1..-1]
vals += checkvals
vals.uniq! # FIXME: ?? error if duplicate values
# FIXME: normalize values? e.g. c: gb and c: GB are same value.
when :delete
nv = nv[1..-1]
if nv.empty?
vals = [] # ?? error if does not exist
else
nv.each { |v| vals.delete(v) } # ?? error if value missing
end
when :replace
vals = checkvals = nv[1..-1]
else
vals = checkvals = nv
end
if vals == []
res.delete(attrname)
else
res[attrname] = vals
end
# Attribute validation
raise LDAP::ResultError::ObjectClassViolation,
"Attribute #{attr} is SINGLE-VALUE" if attr.singlevalue and vals.size > 1
checkvals.each do |val|
raise LDAP::ResultError::InvalidAttributeSyntax,
"Nil or empty value for attribute #{attr}" if val.nil? or val.empty?
raise LDAP::ResultError::InvalidAttributeSyntax,
"Bad value for #{attr}: #{val.inspect}" if attr.syntax and ! attr.syntax.match(val)
raise LDAP::ResultError::InvalidAttributeSyntax,
"Value too long for #{attr} (max #{attr.maxlen})" if attr.maxlen and val.length > attr.maxlen
end
oc_changed = true if attrname == 'objectClass'
end
# Now do objectClass checks
oc = res['objectClass']
unless oc
raise LDAP::ResultError::ObjectClassViolation,
"objectClass attribute missing"
end
oc = oc.collect { |val| find_objectclass(val) }
if oc_changed
# Add superior objectClasses (note: growing an array while you
# iterate over it seems to work, in ruby-1.8.2 anyway!)
oc.each do |objectclass|
objectclass.sup.each do |s|
oc.push(s) unless oc.include?(s)
end
end
res['objectClass'] = oc.collect { |oo| oo.to_s }
# Check that exactly one structural objectClass is present
unless oc.find_all { |s| s.struct == :structural }.size >= 1
raise LDAP::ResultError::ObjectClassViolation,
"Entry must have at least one structural objectClass"
# Exactly one? But you have to sort out the inheritance problem
# (e.g. both person and organizationalPerson are declared
# structural)
end
end
# Ensure that all MUST attributes are present
allow_attr = {}
oc.each do |objectclass|
objectclass.must.each do |m|
unless res[m.name] and res[m.name] != []
raise LDAP::ResultError::ObjectClassViolation, "Missing attribute #{m} required by objectClass #{objectclass}"
end
allow_attr[m.name] = true
end
objectclass.may.each do |m|
allow_attr[m.name] = true
end
end
unless oc.find { |objectclass| objectclass.name == 'extensibleObject' }
# Now check all the attributes given are permitted by MUST or MAY
res.each_key do |attr|
unless allow_attr[attr] or find_attrtype(attr).usage == :directoryOperation
raise LDAP::ResultError::ObjectClassViolation, "Attribute #{attr} not permitted by objectClass"
end
end
end
return res
end
# Hopefully backwards-compatible API for ruby-ldap's LDAP::Schema.
# Since MUST/MAY/SUP may point to schema objects, convert them back
# to strings.
def names(key)
case key
when 'objectClasses'
return all_objectclasses.collect { |e| e.name }
when 'attributeTypes'
return all_attrtypes.collect { |e| e.name }
when 'ldapSyntaxes'
return LDAP::Server::Syntax.all_syntaxes.collect { |e| e.name }
when 'matchingRules'
return LDAP::Server::MatchingRule.all_matching_rules.collect { |e| e.name }
# TODO: matchingRuleUse
end
return nil
end
# Backwards-compatible for ruby-ldap LDAP::Schema
def attr(oc,at)
o = find_objectclass(oc)
case at.upcase
when 'MUST'
return o.must.collect { |e| e.to_s }
when 'MAY'
return o.may.collect { |e| e.to_s }
when 'SUP'
return o.sup.collect { |e| e.to_s }
when 'NAME'
return o.names.collect { |e| e.to_s }
when 'DESC'
return [o.desc]
end
return nil
rescue LDAP::ResultError
return nil
end
# Backwards-compatible for ruby-ldap LDAP::Schema
def must(oc)
attr(oc, "MUST")
end
# Backwards-compatible for ruby-ldap LDAP::Schema
def may(oc)
attr(oc, "MAY")
end
# Backwards-compatible for ruby-ldap LDAP::Schema
def sup(oc)
attr(oc, "SUP")
end
#####################################################################
# Class holding an instance of an AttributeTypeDescription (RFC2252 4.2)
class AttributeType
attr_reader :oid, :names, :desc, :obsolete, :sup, :equality, :ordering
attr_reader :substr, :syntax, :maxlen, :singlevalue, :collective
attr_reader :nousermod, :usage
def initialize(str)
m = LDAP::Server::Syntax::AttributeTypeDescription.match(str)
raise LDAP::ResultError::InvalidAttributeSyntax,
"Bad AttributeTypeDescription #{str.inspect}" unless m
@oid = m[1]
@names = (m[2]||"").scan(/'(.*?)'/).flatten
@desc = m[3]
@obsolete = ! m[4].nil?
@sup = m[5]
@equality = m[6]
@ordering = m[7]
@substr = m[8]
@syntax = m[9]
@maxlen = m[10] && m[10].to_i
@singlevalue = ! m[11].nil?
@collective = ! m[12].nil?
@nousermod = ! m[13].nil?
@usage = m[14] && m[14].intern
# This is the cache of the stringified version. Rather than
# initialize to str, we set nil to force it to be rebuilt
@def = nil
end
def name
@names.first
end
def to_s
(@names && @names.first) || @oid
end
def changed
@def = nil
end
def to_def
return @def if @def
ans = "( #{@oid} "
if @names.nil? or @names.empty?
# nothing
elsif @names.size == 1
ans << "NAME '#{@names.first}' "
else
ans << "NAME ( "
@names.each { |n| ans << "'#{n}' " }
ans << ") "
end
ans << "DESC '#{@desc}' " if @desc
ans << "OBSOLETE " if @obsolete
ans << "SUP #{@sup} " if @sup # oid
ans << "EQUALITY #{@equality} " if @equality # oid
ans << "ORDERING #{@ordering} " if @ordering # oid
ans << "SUBSTR #{@substr} " if @substr # oid
ans << "SYNTAX #{@syntax}#{@maxlen && "{#{@maxlen}}"} " if @syntax
ans << "SINGLE-VALUE " if @singlevalue
ans << "COLLECTIVE " if @collective
ans << "NO-USER-MODIFICATION " if @nousermod
ans << "USAGE #{@usage} " if @usage
ans << ")"
@def = ans
end
end # class AttributeType
#####################################################################
# Class holding an instance of an ObjectClassDescription (RFC2252 4.4)
class ObjectClass
attr_reader :oid, :names, :desc, :obsolete, :sup, :struct, :must, :may
SCAN_WOID = /#{LDAP::Server::Syntax::WOID}/x
def initialize(str)
m = LDAP::Server::Syntax::ObjectClassDescription.match(str)
raise LDAP::ResultError::InvalidAttributeSyntax,
"Bad ObjectClassDescription #{str.inspect}" unless m
@oid = m[1]
@names = (m[2]||"").scan(/'(.*?)'/).flatten
@desc = m[3]
@obsolete = ! m[4].nil?
@sup = (m[5]||"").scan(SCAN_WOID).flatten
@struct = m[6] ? m[6].downcase.intern : :structural
@must = (m[7]||"").scan(SCAN_WOID).flatten
@may = (m[8]||"").scan(SCAN_WOID).flatten
@def = nil
end
def name
@names.first
end
def to_s
(@names && @names.first) || @oid
end
def changed
@def = nil
end
def to_def
return @def if @def
ans = "( #{@oid} "
if @names.nil? or @names.empty?
# nothing
elsif @names.size == 1
ans << "NAME '#{@names.first}' "
else
ans << "NAME ( "
@names.each { |n| ans << "'#{n}' " }
ans << ") "
end
ans << "DESC '#{@desc}' " if @desc
ans << "OBSOLETE " if @obsolete
ans << joinoids("SUP ",@sup," ")
ans << "#{@struct.to_s.upcase} " if @struct
ans << joinoids("MUST ",@must," ")
ans << joinoids("MAY ",@may," ")
ans << ")"
@def = ans
end
def joinoids(pfx,arr,sfx)
return "" unless arr and !arr.empty?
return "#{pfx}#{arr}#{sfx}" unless arr.is_a?(Array)
a = arr.collect { |elem| elem.to_s }
if a.size == 1
return "#{pfx}#{a.first}#{sfx}"
else
return "#{pfx}( #{a.join(" $ ")} )#{sfx}"
end
end
end # class ObjectClass
end # class Schema
end # class Server
end # module LDAP
+123
View File
@@ -0,0 +1,123 @@
require 'ldap/server/connection'
require 'ldap/server/operation'
require 'openssl'
require 'logger'
module LDAP
class Server
attr_accessor :root_dse
DEFAULT_OPT = {
:port=>389,
:nodelay=>true,
}
# Create a new server. Options include all those to tcpserver/preforkserver
# plus:
# either
# :router=>Router - request router instance
# or
# :operation_class=>Class - set Operation handler class
# :operation_args=>[...] - args to Operation.new
#
# :ssl_key_file=>pem, :ssl_cert_file=>pem - enable SSL
# :ssl_ca_path=>directory - verify peer certificates
# :schema=>Schema - Schema object
# :namingContexts=>[dn, ...] - base DN(s) we answer
#
# Specifying a :router always overrides :operation_class
attr_reader :logger
def initialize(opt = DEFAULT_OPT)
@opt = opt
@opt[:server] = self
if @opt[:router]
@opt.delete(:operation_class)
@opt.delete(:operation_args)
else
@opt[:operation_class] ||= LDAP::Server::Operation
@opt[:operation_args] ||= []
end
unless @opt[:logger]
@opt[:logger] ||= Logger.new($stderr)
@opt[:logger].level = Logger::INFO
end
@logger = @opt[:logger]
LDAP::Server.ssl_prepare(@opt)
@schema = opt[:schema] # may be nil
@root_dse = Hash.new { |h,k| h[k] = [] }.merge({
'objectClass' => ['top','openLDAProotDSE','extensibleObject'],
'supportedLDAPVersion' => ['3'],
#'altServer' =>
#'supportedExtension' =>
#'supportedControl' =>
#'supportedSASLMechanisms' =>
})
@root_dse['subschemaSubentry'] = [@schema.subschema_dn] if @schema
@root_dse['namingContexts'] = opt[:namingContexts] if opt[:namingContexts]
end
# create opt[:ssl_ctx] from the other ssl options
def self.ssl_prepare(opt) # :nodoc:
if opt[:ssl_key_file] and opt[:ssl_cert_file]
ctx = OpenSSL::SSL::SSLContext.new
ctx.key = OpenSSL::PKey::RSA.new(File::read(opt[:ssl_key_file]))
ctx.cert = OpenSSL::X509::Certificate.new(File::read(opt[:ssl_cert_file]))
if opt[:ssl_dhparams]
ctx.tmp_dh_callback = proc { |*args|
OpenSSL::PKey::DH.new(
File.read(opt[:ssl_dhparams])
)
}
end
if opt[:ssl_ca_path]
ctx.ca_path = opt[:ssl_ca_path]
ctx.verify_mode = opt[:ssl_verify_mode] ||
OpenSSL::SSL::VERIFY_PEER|OpenSSL::SSL::VERIFY_FAIL_IF_NO_PEER_CERT
elsif opt[:ssl_verify_mode] != 0
$stderr.puts "Warning: No ssl_ca_path, peer certificate won't be verified"
end
opt[:ssl_ctx] = ctx
end
end
def run_tcpserver
require 'ldap/server/tcpserver'
opt = @opt
@thread = LDAP::Server.tcpserver(@opt) do
LDAP::Server::Connection::new(self,opt).handle_requests
end
end
def run_prefork
require 'ldap/server/preforkserver'
opt = @opt
@thread = LDAP::Server.preforkserver(@opt) do
LDAP::Server::Connection::new(self,opt).handle_requests
end
end
def join
begin
@thread.join
rescue Interrupt
@logger.info "Exiting..."
end
end
def stop
@thread.raise Interrupt, "" # <= temporary fix for 1.8.6
begin
@thread.join
rescue Interrupt
# nop
end
end
end # class Server
end # module LDAP
+235
View File
@@ -0,0 +1,235 @@
module LDAP
class Server
# A class which describes LDAP SyntaxDescriptions. For now there is
# a global pool of Syntax objects (rather than each Schema object
# having its own set)
class Syntax
attr_reader :oid, :nhr, :binary, :desc
# Create a new Syntax object
def initialize(oid, desc=nil, opt={}, &blk)
@oid = oid
@desc = desc
@nhr = opt[:nhr] # not human-readable?
@binary = opt[:binary] # binary encoding forced?
@re = opt[:re] # regular expression for parsing
@def = nil
instance_eval(&blk) if blk
end
def to_s
@oid
end
# Create a new Syntax object, given its description string
def self.from_def(str, &blk)
m = LDAPSyntaxDescription.match(str)
raise LDAP::ResultError::InvalidAttributeSyntax,
"Bad SyntaxTypeDescription #{str.inspect}" unless m
new(m[1], m[2], :nhr=>(m[3] == 'TRUE'), :binary=>(m[4] == 'TRUE'), &blk)
end
# Convert this object to its description string
def to_def
return @def if @def
ans = "( #@oid "
ans << "DESC '#@desc' " if @desc
# These are OpenLDAP extensions
ans << "X-BINARY-TRANSFER-REQUIRED 'TRUE' " if @binary
ans << "X-NOT-HUMAN-READABLE 'TRUE' " if @nhr
ans << ")"
@def = ans
end
# Return true or a MatchData object if the given value is allowed
# by this syntax
def match(val)
return true if @re.nil?
@re.match(value_to_s(val))
end
# Convert a value for this syntax into its canonical string representation
# (not yet used, but seemed like a good idea)
def value_to_s(val)
val.to_s
end
# Convert a string value for this syntax into a Ruby-like value
# (not yet used, but seemed like a good idea)
def value_from_s(val)
val
end
@@syntaxes = {}
# Add a new syntax definition
def self.add(*args, &blk)
s = new(*args, &blk)
@@syntaxes[s.oid] = s
end
# Find a Syntax object given an oid. If not known, return a new empty
# Syntax object associated with this oid.
def self.find(oid)
return oid if oid.nil? or oid.is_a?(LDAP::Server::Syntax)
return @@syntaxes[oid] if @@syntaxes[oid]
add(oid)
end
# Return all known syntax objects
def self.all_syntaxes
@@syntaxes.values.uniq
end
# Shared constants for regexp-based syntax parsers
KEYSTR = "[a-zA-Z][a-zA-Z0-9;-]*"
NUMERICOID = "( \\d[\\d.]+\\d )"
WOID = "\\s* ( #{KEYSTR} | \\d[\\d.]+\\d ) \\s*"
_WOID = "\\s* (?: #{KEYSTR} | \\d[\\d.]+\\d ) \\s*"
OIDS = "( #{_WOID} | \\s* \\( #{_WOID} (?: \\$ #{_WOID} )* \\) \\s* )"
_QDESCR = "\\s* ' #{KEYSTR} ' \\s*"
QDESCRS = "( #{_QDESCR} | \\s* \\( (?:#{_QDESCR})+ \\) \\s* )"
QDSTRING = "\\s* ' (.*?) ' \\s*"
NOIDLEN = "(\\d[\\d.]+\\d) (?: \\{ (\\d+) \\} )?"
ATTRIBUTEUSAGE = "(userApplications|directoryOperation|distributedOperation|dSAOperation)"
end
class Syntax
# These are the 'SHOULD' support syntaxes from RFC2252 section 6
AttributeTypeDescription =
add("1.3.6.1.4.1.1466.115.121.1.3", "Attribute Type Description", :re=>
%r! \A \s* \( \s*
#{NUMERICOID} \s*
(?: NAME #{QDESCRS} )?
(?: DESC #{QDSTRING} )?
( OBSOLETE \s* )?
(?: SUP #{WOID} )?
(?: EQUALITY #{WOID} )?
(?: ORDERING #{WOID} )?
(?: SUBSTR #{WOID} )?
(?: SYNTAX \s* #{NOIDLEN} \s* )? # capture 2
( SINGLE-VALUE \s* )?
( COLLECTIVE \s* )?
( NO-USER-MODIFICATION \s* )?
(?: USAGE \s* #{ATTRIBUTEUSAGE} )?
\s* \) \s* \z !xu)
add("1.3.6.1.4.1.1466.115.121.1.5", "Binary", :nhr=>true)
# FIXME: value_to_s should BER-encode the value??
add("1.3.6.1.4.1.1466.115.121.1.6", "Bit String", :re=>/\A'([01]*)'B\z/)
# FIXME: convert to FixNum?
add("1.3.6.1.4.1.1466.115.121.1.7", "Boolean", :re=>/\A(TRUE|FALSE)\z/) do
def self.value_to_s(v)
return v if v.is_a?(string)
v ? "TRUE" : "FALSE"
end
def self.value_from_s(v)
v.upcase == "TRUE"
end
end
add("1.3.6.1.4.1.1466.115.121.1.8", "Certificate", :binary=>true, :nhr=>true)
add("1.3.6.1.4.1.1466.115.121.1.9", "Certificate List", :binary=>true, :nhr=>true)
add("1.3.6.1.4.1.1466.115.121.1.10", "Certificate Pair", :binary=>true, :nhr=>true)
add("1.3.6.1.4.1.1466.115.121.1.11", "Country String", :re=>/\A[A-Z]{2}\z/i)
add("1.3.6.1.4.1.1466.115.121.1.12", "Distinguished Name")
# FIXME: validate DN?
add("1.3.6.1.4.1.1466.115.121.1.15", "Directory String")
# missed due to lack of interest: "DIT Content Rule Description"
add("1.3.6.1.4.1.1466.115.121.1.22", "Facsimile Telephone Number")
add(" 1.3.6.1.4.1.1466.115.121.1.23", "Fax", :nhr=>true)
add("1.3.6.1.4.1.1466.115.121.1.24", "Generalized Time")
# FIXME: Validate Generalized Time (find X.208) and convert to/from Ruby Time
add("1.3.6.1.4.1.1466.115.121.1.26", "IA5 String")
add("1.3.6.1.4.1.1466.115.121.1.27", "Integer", :re=>/\A\d+\z/) do
def self.value_from_s(v)
v.to_i
end
end
add("1.3.6.1.4.1.1466.115.121.1.28", "JPEG", :nhr=>true)
MatchingRuleDescription =
add("1.3.6.1.4.1.1466.115.121.1.30", "Matching Rule Description", :re=>
%r! \A \s* \( \s*
#{NUMERICOID} \s*
(?: NAME #{QDESCRS} )?
(?: DESC #{QDSTRING} )?
( OBSOLETE \s* )?
SYNTAX \s* #{NUMERICOID} \s*
\s* \) \s* \z !xu)
MatchingRuleUseDescription =
add("1.3.6.1.4.1.1466.115.121.1.31", "Matching Rule Use Description", :re=>
%r! \A \s* \( \s*
#{NUMERICOID} \s*
(?: NAME #{QDESCRS} )?
(?: DESC #{QDSTRING} )?
( OBSOLETE \s* )?
APPLIES \s* #{OIDS} \s*
\s* \) \s* \z !xu)
add("1.3.6.1.4.1.1466.115.121.1.33", "MHS OR Address")
add("1.3.6.1.4.1.1466.115.121.1.34", "Name And Optional UID")
# missed due to lack of interest: "Name Form Description"
add("1.3.6.1.4.1.1466.115.121.1.36", "Numeric String", :re=>/\A\d+\z/)
ObjectClassDescription =
add("1.3.6.1.4.1.1466.115.121.1.37", "Object Class Description", :re=>
%r! \A \s* \( \s*
#{NUMERICOID} \s*
(?: NAME #{QDESCRS} )?
(?: DESC #{QDSTRING} )?
( OBSOLETE \s* )?
(?: SUP #{OIDS} )?
(?: ( ABSTRACT|STRUCTURAL|AUXILIARY ) \s* )?
(?: MUST #{OIDS} )?
(?: MAY #{OIDS} )?
\s* \) \s* \z !xu)
add("1.3.6.1.4.1.1466.115.121.1.38", "OID", :re=>/\A#{WOID}\z/xu)
add("1.3.6.1.4.1.1466.115.121.1.39", "Other Mailbox")
add("1.3.6.1.4.1.1466.115.121.1.41", "Postal Address") do
def self.value_from_s(v)
v.split(/\$/)
end
def self.value_to_s(v)
return v.join("$") if v.is_a?(Array)
return v
end
end
add("1.3.6.1.4.1.1466.115.121.1.43", "Presentation Address")
add("1.3.6.1.4.1.1466.115.121.1.44", "Printable String")
add("1.3.6.1.4.1.1466.115.121.1.50", "Telephone Number")
add("1.3.6.1.4.1.1466.115.121.1.53", "UTC Time")
LDAPSyntaxDescription =
add("1.3.6.1.4.1.1466.115.121.1.54", "LDAP Syntax Description", :re=>
%r! \A \s* \( \s*
#{NUMERICOID} \s*
(?: DESC #{QDSTRING} )?
(?: X-BINARY-TRANSFER-REQUIRED \s* ' (TRUE|FALSE) ' \s* )?
(?: X-NOT-HUMAN-READABLE \s* ' (TRUE|FALSE) ' \s* )?
\s* \) \s* \z !xu)
# Missed due to lack of interest: "DIT Structure Rule Description"
# A few others from RFC2252 section 4.3.2
add("1.3.6.1.4.1.1466.115.121.1.4", "Audio", :nhr=>true)
add("1.3.6.1.4.1.1466.115.121.1.40", "Octet String")
add("1.3.6.1.4.1.1466.115.121.1.58", "Substring Assertion")
end
end # class Server
end # module LDAP
+102
View File
@@ -0,0 +1,102 @@
require 'socket'
require 'fileutils'
module LDAP
class Server
# Accept connections on a port, and for each one start a new thread
# and run the given block. Returns the Thread object for the listener.
#
# FIXME:
# - have a limit on total number of concurrent connects
# - have a limit on connections from a single IP, or from a /24
# (to avoid the trivial DoS that the first limit creates)
# - ACL using source IP address (or perhaps that belongs in application)
#
# Options:
# :port=>port number [required]
# :bindaddr=>"IP address"
# :user=>"username" - drop privileges after bind
# :group=>"groupname" - ditto
# :logger=>object - implements << method
# :listen=>number - listen queue depth
# :nodelay=>true - set TCP_NODELAY option
def self.tcpserver(opt, &blk)
if opt[:socket]
server = UNIXServer.new(opt[:socket])
FileUtils.chmod(0777, opt[:socket])
else
server = TCPServer.new(opt[:bindaddr] || "0.0.0.0", opt[:port])
end
# Drop privileges if requested
require 'etc' if opt[:group] or opt[:user]
Process.gid = Process.egid = Etc.getgrnam(opt[:group]).gid if opt[:group]
Process.uid = Process.euid = Etc.getpwnam(opt[:user]).uid if opt[:user]
Process.gid = opt[:gid] if opt[:gid]
Process.uid = opt[:uid] if opt[:uid]
if opt[:socket]
FileUtils.chown((opt[:user] || opt[:uid]), (opt[:group] || opt[:gid]), opt[:socket])
end
# Typically the O/S will buffer response data for 100ms before sending.
# If the response is sent as a single write() then there's no need for it.
if opt[:nodelay]
begin
server.setsockopt(Socket::IPPROTO_TCP, Socket::TCP_NODELAY, 1)
rescue Exception
end
end
# set queue size for incoming connections (default is 5)
server.listen(opt[:listen]) if opt[:listen]
Thread.new do
while true
begin
session = server.accept
# subtlety: copy 'session' into a block-local variable because
# it will change when the next session is accepted
Thread.new(session) do |s|
begin
s.instance_eval(&blk)
rescue Exception => e
opt[:logger].error(s.peeraddr[3]) {"#{e}: #{e.backtrace[0]}"}
ensure
s.close
end
end
rescue Interrupt
# This exception can be raised to shut the server down
server.close if server and not server.closed?
break
end
end
end
end
end # class Server
end # module LDAP
if __FILE__ == $0
# simple test
puts "Running a test POP3 server on port 1110"
t = LDAP::Server.tcpserver(:port=>1110) do
print "+OK I am a fake POP3 server\r\n"
while line = gets
case line
when /^quit/i
break
when /^crash/i
raise Errno::EPERM, "dammit!"
else
print "-ERR I don't understand #{line}"
end
end
print "+OK bye\r\n"
end
#sleep 10; t.raise Interrupt # uncomment to run for fixed time period
t.join
end
+92
View File
@@ -0,0 +1,92 @@
require 'ldap/server/dn'
module LDAP
class Server
class Trie
# Trie or prefix tree suitable for storing LDAP paths
# Variables (wildcards) are supported
class NodeNotFoundError < Error; end
attr_accessor :parent, :value, :children
# Create a new Trie. Use with a block
def initialize(parent = nil, value = nil)
@parent = parent
@value = value
@children = Hash.new
yield self if block_given?
end
# Insert a path (empty node)
def <<(dn)
insert(dn)
end
# Insert a node with a value
def insert(dn, value = nil)
dn = LDAP::Server::DN.new(dn || '') if not dn.is_a? LDAP::Server::DN
dn.reverse_each do |component|
@children[component] = Trie.new(self) if @children[component].nil?
dn.dname.pop
if dn.any?
@children[component].insert dn, value
else
@children[component].value = value
end
end
end
# Looks up a node and returns its value or raises
# LDAP::Server::Trie::NodeNotFoundError if it's not in the tree
def lookup(dn)
dn = LDAP::Server::DN.new(dn || '') if not dn.is_a? LDAP::Server::DN
return @value if dn.dname.empty?
component = dn.dname.pop
@children.each do |key, value|
if key.keys.first == component.keys.first
if key.values.first.start_with?(':') or key.values.first == component.values.first
return value.lookup dn
end
end
end
raise NodeNotFoundError
end
# Looks up a node and returns its value or the (non-nil) value of
# the nearest ancestor.
def match(dn, path = '')
dn = LDAP::Server::DN.new(dn || '') if not dn.is_a? LDAP::Server::DN
return path, @value if dn.dname.empty?
component = dn.dname.pop
@children.each do |key, value|
if key.keys.first == component.keys.first
if key.values.first.start_with?(':') or key.values.first == component.values.first
path.prepend ',' unless path.empty?
path.prepend "#{LDAP::Server::DN.join key}"
new_path, new_value = value.match dn, path
if new_value
return new_path, new_value
else
return (@value ? path : nil), @value
end
end
end
end
return path, @value
end
def print_tree(prefix = '')
if @value
p "#{prefix}{{#{@value}}}"
end
@children.each do |key, value|
p "#{prefix}#{key.keys.first} => #{key.values.first}"
@children[key].print_tree("#{prefix} ")
end
end
end
end
end
+88
View File
@@ -0,0 +1,88 @@
require 'ldap/server/result'
module LDAP
class Server
class Operation
# Return true if connection is not authenticated
def anonymous?
@connection.binddn.nil?
end
# Split dn string into its component parts, returning
# [ {attr=>val}, {attr=>val}, ... ]
#
# This is pretty horrible legacy stuff from X500; see RFC2253 for the
# full gore. It's stupid that the LDAP protocol sends the DN in string
# form, rather than in ASN1 form (as it does with search filters, for
# example), even though the DN syntax is defined in terms of ASN1!
#
# Attribute names are downcased, but values are not. For any
# case-insensitive attributes it's up to you to downcase them.
#
# Note that only v2 clients should add extra space around the comma.
# This is accepted, and so is semicolon instead of comma, but the
# full RFC1779 backwards-compatibility rules (e.g. quoted values)
# are not implemented.
#
# I *think* these functions will work correctly with UTF8-encoded
# characters, given that a multibyte UTF8 character does not contain
# the bytes 00-7F and therefore we cannot confuse '\', '+' etc
def self.split_dn(dn)
# convert \\ to \5c, \+ to \2b etc
dn.gsub!(/\\([ #,+"\\<>;])/) { |match| format "\\%02x", match[1].ord }
# Now we know that \\ and \, do not exist, it's safe to split
parts = dn.split(/\s*[,;]\s*/)
parts.collect do |part|
res = {}
# Split each part into attr=val+attr=val
avs = part.split(/\+/)
avs.each do |av|
# These should all be of form attr=value
unless av =~ /^([^=]+)=(.*)$/
raise LDAP::ResultError::ProtocolError, "Bad DN component: #{av}"
end
attr, val = $1.downcase, $2
# Now we can decode those bits
attr.gsub!(/\\([a-f0-9][a-f0-9])/i) { $1.hex.chr }
val.gsub!(/\\([a-f0-9][a-f0-9])/i) { $1.hex.chr }
res[attr] = val
end
res
end
end
# Reverse of split_dn. Join [elements...]
# where each element can be {attr=>val,...} or [[attr,val],...]
# or just [attr,val]
def self.join_dn(elements)
dn = ""
elements.each do |elem|
av = ""
elem = [elem] if elem[0].is_a?(String)
elem.each do |attr,val|
av << "+" unless av == ""
av << attr << "=" <<
val.sub(/^([# ])/, '\\\\\\1').
sub(/( )$/, '\\\\\\1').
gsub(/([,+"\\<>;])/, '\\\\\\1')
end
dn << "," unless dn == ""
dn << av
end
dn
end
end # class Operation
end # class Server
end # module LDAP
+5
View File
@@ -0,0 +1,5 @@
module LDAP #:nodoc:
class Server #:nodoc:
VERSION = '0.7.0'
end
end