ruby-ldapserver 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- data/COPYING +27 -0
- data/ChangeLog +83 -0
- data/Manifest.txt +32 -0
- data/README +222 -0
- data/Rakefile +22 -0
- data/examples/README +89 -0
- data/examples/mkcert.rb +31 -0
- data/examples/rbslapd1.rb +111 -0
- data/examples/rbslapd2.rb +161 -0
- data/examples/rbslapd3.rb +172 -0
- data/examples/speedtest.rb +37 -0
- data/lib/ldap/server.rb +4 -0
- data/lib/ldap/server/connection.rb +273 -0
- data/lib/ldap/server/filter.rb +223 -0
- data/lib/ldap/server/match.rb +283 -0
- data/lib/ldap/server/operation.rb +487 -0
- data/lib/ldap/server/preforkserver.rb +93 -0
- data/lib/ldap/server/result.rb +71 -0
- data/lib/ldap/server/schema.rb +592 -0
- data/lib/ldap/server/server.rb +89 -0
- data/lib/ldap/server/syntax.rb +235 -0
- data/lib/ldap/server/tcpserver.rb +91 -0
- data/lib/ldap/server/util.rb +88 -0
- data/lib/ldap/server/version.rb +11 -0
- data/test/core.schema +582 -0
- data/test/encoding_test.rb +279 -0
- data/test/filter_test.rb +107 -0
- data/test/match_test.rb +59 -0
- data/test/schema_test.rb +113 -0
- data/test/syntax_test.rb +40 -0
- data/test/test_helper.rb +2 -0
- data/test/util_test.rb +51 -0
- metadata +98 -0
data/examples/mkcert.rb
ADDED
@@ -0,0 +1,31 @@
|
|
1
|
+
require 'openssl'
|
2
|
+
|
3
|
+
# Taken directly from echo_svr.rb in the Ruby openssl examples
|
4
|
+
|
5
|
+
key = OpenSSL::PKey::RSA.new(1024){ print "."; $stdout.flush }
|
6
|
+
puts
|
7
|
+
cert = OpenSSL::X509::Certificate.new
|
8
|
+
cert.version = 2
|
9
|
+
cert.serial = 0
|
10
|
+
name = OpenSSL::X509::Name.new([["C","JP"],["O","TEST"],["CN","localhost"]])
|
11
|
+
cert.subject = name
|
12
|
+
cert.issuer = name
|
13
|
+
cert.not_before = Time.now
|
14
|
+
cert.not_after = Time.now + 3600
|
15
|
+
cert.public_key = key.public_key
|
16
|
+
ef = OpenSSL::X509::ExtensionFactory.new(nil,cert)
|
17
|
+
cert.extensions = [
|
18
|
+
ef.create_extension("basicConstraints","CA:FALSE"),
|
19
|
+
ef.create_extension("subjectKeyIdentifier","hash"),
|
20
|
+
ef.create_extension("extendedKeyUsage","serverAuth"),
|
21
|
+
ef.create_extension("keyUsage",
|
22
|
+
"keyEncipherment,dataEncipherment,digitalSignature")
|
23
|
+
]
|
24
|
+
ef.issuer_certificate = cert
|
25
|
+
cert.add_extension ef.create_extension("authorityKeyIdentifier",
|
26
|
+
"keyid:always,issuer:always")
|
27
|
+
cert.sign(key, OpenSSL::Digest::SHA1.new)
|
28
|
+
|
29
|
+
# Write to disk
|
30
|
+
File.open("key.pem","w",0600) { |f| f << key.to_pem }
|
31
|
+
File.open("cert.pem","w",0644) { |f| f << cert.to_pem }
|
@@ -0,0 +1,111 @@
|
|
1
|
+
#!/usr/local/bin/ruby -w
|
2
|
+
|
3
|
+
# This is a trivial LDAP server which just stores directory entries in RAM.
|
4
|
+
# It does no validation or authentication. This is intended just to
|
5
|
+
# demonstrate the API, it's not for real-world use!!
|
6
|
+
|
7
|
+
$:.unshift('../lib')
|
8
|
+
$debug = true
|
9
|
+
|
10
|
+
require 'ldap/server'
|
11
|
+
|
12
|
+
# We subclass the Operation class, overriding the methods to do what we need
|
13
|
+
|
14
|
+
class HashOperation < LDAP::Server::Operation
|
15
|
+
def initialize(connection, messageID, hash)
|
16
|
+
super(connection, messageID)
|
17
|
+
@hash = hash # an object reference to our directory data
|
18
|
+
end
|
19
|
+
|
20
|
+
def search(basedn, scope, deref, filter)
|
21
|
+
basedn.downcase!
|
22
|
+
|
23
|
+
case scope
|
24
|
+
when LDAP::Server::BaseObject
|
25
|
+
# client asked for single object by DN
|
26
|
+
obj = @hash[basedn]
|
27
|
+
raise LDAP::ResultError::NoSuchObject unless obj
|
28
|
+
send_SearchResultEntry(basedn, obj) if LDAP::Server::Filter.run(filter, obj)
|
29
|
+
|
30
|
+
when LDAP::Server::WholeSubtree
|
31
|
+
@hash.each do |dn, av|
|
32
|
+
next unless dn.index(basedn, -basedn.length) # under basedn?
|
33
|
+
next unless LDAP::Server::Filter.run(filter, av) # attribute filter?
|
34
|
+
send_SearchResultEntry(dn, av)
|
35
|
+
end
|
36
|
+
|
37
|
+
else
|
38
|
+
raise LDAP::ResultError::UnwillingToPerform, "OneLevel not implemented"
|
39
|
+
|
40
|
+
end
|
41
|
+
end
|
42
|
+
|
43
|
+
def add(dn, av)
|
44
|
+
dn.downcase!
|
45
|
+
raise LDAP::ResultError::EntryAlreadyExists if @hash[dn]
|
46
|
+
@hash[dn] = av
|
47
|
+
end
|
48
|
+
|
49
|
+
def del(dn)
|
50
|
+
dn.downcase!
|
51
|
+
raise LDAP::ResultError::NoSuchObject unless @hash.has_key?(dn)
|
52
|
+
@hash.delete(dn)
|
53
|
+
end
|
54
|
+
|
55
|
+
def modify(dn, ops)
|
56
|
+
entry = @hash[dn]
|
57
|
+
raise LDAP::ResultError::NoSuchObject unless entry
|
58
|
+
ops.each do |attr, vals|
|
59
|
+
op = vals.shift
|
60
|
+
case op
|
61
|
+
when :add
|
62
|
+
entry[attr] ||= []
|
63
|
+
entry[attr] += vals
|
64
|
+
entry[attr].uniq!
|
65
|
+
when :delete
|
66
|
+
if vals == []
|
67
|
+
entry.delete(attr)
|
68
|
+
else
|
69
|
+
vals.each { |v| entry[attr].delete(v) }
|
70
|
+
end
|
71
|
+
when :replace
|
72
|
+
entry[attr] = vals
|
73
|
+
end
|
74
|
+
entry.delete(attr) if entry[attr] == []
|
75
|
+
end
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# This is the shared object which carries our actual directory entries.
|
80
|
+
# It's just a hash of {dn=>entry}, where each entry is {attr=>[val,val,...]}
|
81
|
+
|
82
|
+
directory = {}
|
83
|
+
|
84
|
+
# Let's put some backing store on it
|
85
|
+
|
86
|
+
require 'yaml'
|
87
|
+
begin
|
88
|
+
File.open("ldapdb.yaml") { |f| directory = YAML::load(f.read) }
|
89
|
+
rescue Errno::ENOENT
|
90
|
+
end
|
91
|
+
|
92
|
+
at_exit do
|
93
|
+
File.open("ldapdb.new","w") { |f| f.write(YAML::dump(directory)) }
|
94
|
+
File.rename("ldapdb.new","ldapdb.yaml")
|
95
|
+
end
|
96
|
+
|
97
|
+
# Listen for incoming LDAP connections. For each one, create a Connection
|
98
|
+
# object, which will invoke a HashOperation object for each request.
|
99
|
+
|
100
|
+
s = LDAP::Server.new(
|
101
|
+
:port => 1389,
|
102
|
+
:nodelay => true,
|
103
|
+
:listen => 10,
|
104
|
+
# :ssl_key_file => "key.pem",
|
105
|
+
# :ssl_cert_file => "cert.pem",
|
106
|
+
# :ssl_on_connect => true,
|
107
|
+
:operation_class => HashOperation,
|
108
|
+
:operation_args => [directory]
|
109
|
+
)
|
110
|
+
s.run_tcpserver
|
111
|
+
s.join
|
@@ -0,0 +1,161 @@
|
|
1
|
+
#!/usr/local/bin/ruby -w
|
2
|
+
|
3
|
+
$:.unshift('../lib')
|
4
|
+
require 'ldap/server'
|
5
|
+
require 'mysql' # <http://www.tmtm.org/en/ruby/mysql/>
|
6
|
+
require 'thread'
|
7
|
+
require 'resolv-replace' # ruby threading DNS client
|
8
|
+
|
9
|
+
# An example of an LDAP to SQL gateway. We have a MySQL table which
|
10
|
+
# contains (login_id,login,passwd) combinations, e.g.
|
11
|
+
#
|
12
|
+
# +----------+----------+--------+
|
13
|
+
# | login_id | login | passwd |
|
14
|
+
# +----------+----------+--------+
|
15
|
+
# | 1 | brian | foobar |
|
16
|
+
# | 2 | caroline | boing |
|
17
|
+
# +----------+----------+--------+
|
18
|
+
#
|
19
|
+
# We support LDAP searches for (uid=login), returning a synthesised DN and
|
20
|
+
# Maildir attribute, and we support LDAP binds to validate passwords. We
|
21
|
+
# keep a cache of recent lookups so that a bind to validate a password
|
22
|
+
# doesn't cause a second SQL query. Since we're multi-threaded, this should
|
23
|
+
# work even if the bind occurs on a different client connection to the search.
|
24
|
+
#
|
25
|
+
# To test:
|
26
|
+
# ldapsearch -H ldap://127.0.0.1:1389/ -b "dc=example,dc=com" "(uid=brian)"
|
27
|
+
#
|
28
|
+
# ldapsearch -H ldap://127.0.0.1:1389/ -b "dc=example,dc=com" \
|
29
|
+
# -D "id=1,dc=example,dc=com" -W "(uid=brian)"
|
30
|
+
|
31
|
+
$debug = true
|
32
|
+
SQL_CONNECT = ["1.2.3.4", "myuser", "mypass", "mydb"]
|
33
|
+
TABLE = "logins"
|
34
|
+
SQL_POOL_SIZE = 5
|
35
|
+
PW_CACHE_SIZE = 100
|
36
|
+
BASEDN = "dc=example,dc=com"
|
37
|
+
LDAP_PORT = 1389
|
38
|
+
|
39
|
+
# A thread-safe pool of persistent MySQL connections
|
40
|
+
|
41
|
+
class SQLPool
|
42
|
+
def initialize(n, *args)
|
43
|
+
@args = args
|
44
|
+
@pool = Queue.new # this is a thread-safe queue
|
45
|
+
n.times { @pool.push nil } # create connections on demand
|
46
|
+
end
|
47
|
+
|
48
|
+
def borrow
|
49
|
+
conn = @pool.pop || Mysql::new(*@args)
|
50
|
+
yield conn
|
51
|
+
rescue Exception
|
52
|
+
conn = nil # put 'nil' back into the pool
|
53
|
+
raise
|
54
|
+
ensure
|
55
|
+
@pool.push conn
|
56
|
+
end
|
57
|
+
end
|
58
|
+
|
59
|
+
# An simple LRU cache of username->password. It's linearly searched
|
60
|
+
# so don't make it too big.
|
61
|
+
|
62
|
+
class LRUCache
|
63
|
+
def initialize(size)
|
64
|
+
@size = size
|
65
|
+
@cache = [] # [[key,val],[key,val],...]
|
66
|
+
@mutex = Mutex.new
|
67
|
+
end
|
68
|
+
|
69
|
+
def add(id,data)
|
70
|
+
@mutex.synchronize do
|
71
|
+
@cache.delete_if { |k,v| k == id }
|
72
|
+
@cache.unshift [id,data]
|
73
|
+
@cache.pop while @cache.size > @size
|
74
|
+
end
|
75
|
+
end
|
76
|
+
|
77
|
+
def find(id)
|
78
|
+
@mutex.synchronize do
|
79
|
+
index = entry = nil
|
80
|
+
@cache.each_with_index do |e, i|
|
81
|
+
if e[0] == id
|
82
|
+
entry = e
|
83
|
+
index = i
|
84
|
+
break
|
85
|
+
end
|
86
|
+
end
|
87
|
+
return nil unless index
|
88
|
+
@cache.delete_at(index)
|
89
|
+
@cache.unshift entry
|
90
|
+
return entry[1]
|
91
|
+
end
|
92
|
+
end
|
93
|
+
end
|
94
|
+
|
95
|
+
|
96
|
+
class SQLOperation < LDAP::Server::Operation
|
97
|
+
def self.setcache(cache,pool)
|
98
|
+
@@cache = cache
|
99
|
+
@@pool = pool
|
100
|
+
end
|
101
|
+
|
102
|
+
# Handle searches of the form "(uid=<foo>)" using SQL backend
|
103
|
+
# (uid=foo) => [:eq, "uid", matchobj, "foo"]
|
104
|
+
|
105
|
+
def search(basedn, scope, deref, filter)
|
106
|
+
raise LDAP::ResultError::UnwillingToPerform, "Bad base DN" unless basedn == BASEDN
|
107
|
+
raise LDAP::ResultError::UnwillingToPerform, "Bad filter" unless filter[0..1] == [:eq, "uid"]
|
108
|
+
uid = filter[3]
|
109
|
+
@@pool.borrow do |sql|
|
110
|
+
q = "select login_id,passwd from #{TABLE} where login='#{sql.quote(uid)}'"
|
111
|
+
puts "SQL Query #{sql.object_id}: #{q}" if $debug
|
112
|
+
res = sql.query(q)
|
113
|
+
res.each do |login_id,passwd|
|
114
|
+
@@cache.add(login_id, passwd)
|
115
|
+
send_SearchResultEntry("id=#{login_id},#{BASEDN}", {
|
116
|
+
"maildir"=>["/netapp/#{uid}/"],
|
117
|
+
})
|
118
|
+
end
|
119
|
+
end
|
120
|
+
end
|
121
|
+
|
122
|
+
# Validate passwords
|
123
|
+
|
124
|
+
def simple_bind(version, dn, password)
|
125
|
+
return if dn.nil? # accept anonymous
|
126
|
+
|
127
|
+
raise LDAP::ResultError::UnwillingToPerform unless dn =~ /\Aid=(\d+),#{BASEDN}\z/
|
128
|
+
login_id = $1
|
129
|
+
dbpw = @@cache.find(login_id)
|
130
|
+
unless dbpw
|
131
|
+
@@pool.borrow do |sql|
|
132
|
+
q = "select passwd from #{TABLE} where login_id=#{login_id}"
|
133
|
+
puts "SQL Query #{sql.object_id}: #{q}" if $debug
|
134
|
+
res = sql.query(q)
|
135
|
+
if res.num_rows == 1
|
136
|
+
dbpw = res.fetch_row[0]
|
137
|
+
@@cache.add(login_id, dbpw)
|
138
|
+
end
|
139
|
+
end
|
140
|
+
end
|
141
|
+
raise LDAP::ResultError::InvalidCredentials unless dbpw and dbpw != "" and dbpw == password
|
142
|
+
end
|
143
|
+
end
|
144
|
+
|
145
|
+
# Build the objects we need
|
146
|
+
|
147
|
+
cache = LRUCache.new(PW_CACHE_SIZE)
|
148
|
+
pool = SQLPool.new(SQL_POOL_SIZE, *SQL_CONNECT)
|
149
|
+
SQLOperation.setcache(cache,pool)
|
150
|
+
|
151
|
+
s = LDAP::Server.new(
|
152
|
+
:port => LDAP_PORT,
|
153
|
+
:nodelay => true,
|
154
|
+
:listen => 10,
|
155
|
+
# :ssl_key_file => "key.pem",
|
156
|
+
# :ssl_cert_file => "cert.pem",
|
157
|
+
# :ssl_on_connect => true,
|
158
|
+
:operation_class => SQLOperation
|
159
|
+
)
|
160
|
+
s.run_tcpserver
|
161
|
+
s.join
|
@@ -0,0 +1,172 @@
|
|
1
|
+
#!/usr/local/bin/ruby -w
|
2
|
+
|
3
|
+
# This is similar to rbslapd1.rb but here we use TOMITA Masahiro's prefork
|
4
|
+
# library: <http://raa.ruby-lang.org/project/prefork/>
|
5
|
+
# Advantages over Ruby threading:
|
6
|
+
# - each client connection is handled in its own process; don't need
|
7
|
+
# to worry about Ruby thread blocking (except if one client issues
|
8
|
+
# overlapping LDAP operations down the same connection, which is uncommon)
|
9
|
+
# - better scalability on multi-processor systems
|
10
|
+
# - better scalability on single-processor systems (e.g. shouldn't hit
|
11
|
+
# max FDs per process limit)
|
12
|
+
# Disadvantages:
|
13
|
+
# - client connections can't share state in RAM. So our shared directory
|
14
|
+
# now has to be read from disk, and flushed to disk after every update.
|
15
|
+
#
|
16
|
+
# Additionally, I have added schema support. An LDAP v3 client can
|
17
|
+
# query the schema remotely, and adds/modifies have data validated.
|
18
|
+
|
19
|
+
$:.unshift('../lib')
|
20
|
+
|
21
|
+
require 'ldap/server'
|
22
|
+
require 'ldap/server/schema'
|
23
|
+
require 'yaml'
|
24
|
+
|
25
|
+
$debug = nil # $stderr
|
26
|
+
|
27
|
+
# An object to keep our in-RAM database and synchronise it to disk
|
28
|
+
# when necessary
|
29
|
+
|
30
|
+
class Directory
|
31
|
+
attr_reader :data
|
32
|
+
|
33
|
+
def initialize(filename)
|
34
|
+
@filename = filename
|
35
|
+
@stat = nil
|
36
|
+
update
|
37
|
+
end
|
38
|
+
|
39
|
+
# synchronise with directory on disk (re-read if it has changed)
|
40
|
+
|
41
|
+
def update
|
42
|
+
begin
|
43
|
+
tmp = {}
|
44
|
+
sb = File.stat(@filename)
|
45
|
+
return if @stat and @stat.ino == sb.ino and @stat.mtime == sb.mtime
|
46
|
+
File.open(@filename) do |f|
|
47
|
+
tmp = YAML::load(f.read)
|
48
|
+
@stat = f.stat
|
49
|
+
end
|
50
|
+
rescue Errno::ENOENT
|
51
|
+
end
|
52
|
+
@data = tmp
|
53
|
+
end
|
54
|
+
|
55
|
+
# write back to disk
|
56
|
+
|
57
|
+
def write
|
58
|
+
File.open(@filename+".new","w") { |f| f.write(YAML::dump(@data)) }
|
59
|
+
File.rename(@filename+".new",@filename)
|
60
|
+
@stat = File.stat(@filename)
|
61
|
+
end
|
62
|
+
|
63
|
+
# run a block while holding a lock on the database
|
64
|
+
|
65
|
+
def lock
|
66
|
+
File.open(@filename+".lock","w") do |f|
|
67
|
+
f.flock(File::LOCK_EX) # will block here until lock available
|
68
|
+
yield
|
69
|
+
end
|
70
|
+
end
|
71
|
+
end
|
72
|
+
|
73
|
+
# We subclass the Operation class, overriding the methods to do what we need
|
74
|
+
|
75
|
+
class DirOperation < LDAP::Server::Operation
|
76
|
+
def initialize(connection, messageID, dir)
|
77
|
+
super(connection, messageID)
|
78
|
+
@dir = dir
|
79
|
+
end
|
80
|
+
|
81
|
+
def search(basedn, scope, deref, filter)
|
82
|
+
$debug << "Search: basedn=#{basedn.inspect}, scope=#{scope.inspect}, deref=#{deref.inspect}, filter=#{filter.inspect}\n" if $debug
|
83
|
+
basedn.downcase!
|
84
|
+
|
85
|
+
case scope
|
86
|
+
when LDAP::Server::BaseObject
|
87
|
+
# client asked for single object by DN
|
88
|
+
@dir.update
|
89
|
+
obj = @dir.data[basedn]
|
90
|
+
raise LDAP::ResultError::NoSuchObject unless obj
|
91
|
+
ok = LDAP::Server::Filter.run(filter, obj)
|
92
|
+
$debug << "Match=#{ok.inspect}: #{obj.inspect}\n" if $debug
|
93
|
+
send_SearchResultEntry(basedn, obj) if ok
|
94
|
+
|
95
|
+
when LDAP::Server::WholeSubtree
|
96
|
+
@dir.update
|
97
|
+
@dir.data.each do |dn, av|
|
98
|
+
$debug << "Considering #{dn}\n" if $debug
|
99
|
+
next unless dn.index(basedn, -basedn.length) # under basedn?
|
100
|
+
next unless LDAP::Server::Filter.run(filter, av) # attribute filter?
|
101
|
+
$debug << "Sending: #{av.inspect}\n" if $debug
|
102
|
+
send_SearchResultEntry(dn, av)
|
103
|
+
end
|
104
|
+
|
105
|
+
else
|
106
|
+
raise LDAP::ResultError::UnwillingToPerform, "OneLevel not implemented"
|
107
|
+
|
108
|
+
end
|
109
|
+
end
|
110
|
+
|
111
|
+
def add(dn, entry)
|
112
|
+
entry = @schema.validate(entry)
|
113
|
+
entry['createTimestamp'] = [Time.now.gmtime.strftime("%Y%m%d%H%MZ")]
|
114
|
+
entry['creatorsName'] = [@connection.binddn.to_s]
|
115
|
+
# FIXME: normalize the DN and check it's below our root DN
|
116
|
+
# FIXME: validate that a superior object exists
|
117
|
+
# FIXME: validate that entry contains the RDN attribute (yuk)
|
118
|
+
dn.downcase!
|
119
|
+
@dir.lock do
|
120
|
+
@dir.update
|
121
|
+
raise LDAP::ResultError::EntryAlreadyExists if @dir.data[dn]
|
122
|
+
@dir.data[dn] = entry
|
123
|
+
@dir.write
|
124
|
+
end
|
125
|
+
end
|
126
|
+
|
127
|
+
def del(dn)
|
128
|
+
dn.downcase!
|
129
|
+
@dir.lock do
|
130
|
+
@dir.update
|
131
|
+
raise LDAP::ResultError::NoSuchObject unless @dir.data.has_key?(dn)
|
132
|
+
@dir.data.delete(dn)
|
133
|
+
@dir.write
|
134
|
+
end
|
135
|
+
end
|
136
|
+
|
137
|
+
def modify(dn, ops)
|
138
|
+
dn.downcase!
|
139
|
+
@dir.lock do
|
140
|
+
@dir.update
|
141
|
+
entry = @dir.data[dn]
|
142
|
+
raise LDAP::ResultError::NoSuchObject unless entry
|
143
|
+
entry = @schema.validate(ops, entry) # also does the update
|
144
|
+
entry['modifyTimestamp'] = [Time.now.gmtime.strftime("%Y%m%d%H%MZ")]
|
145
|
+
entry['modifiersName'] = [@connection.binddn.to_s]
|
146
|
+
@dir.data[dn] = entry
|
147
|
+
@dir.write
|
148
|
+
end
|
149
|
+
end
|
150
|
+
end
|
151
|
+
|
152
|
+
directory = Directory.new("ldapdb.yaml")
|
153
|
+
|
154
|
+
schema = LDAP::Server::Schema.new
|
155
|
+
schema.load_system
|
156
|
+
schema.load_file("../test/core.schema")
|
157
|
+
schema.resolve_oids
|
158
|
+
|
159
|
+
s = LDAP::Server.new(
|
160
|
+
:port => 1389,
|
161
|
+
:nodelay => true,
|
162
|
+
:listen => 10,
|
163
|
+
# :ssl_key_file => "key.pem",
|
164
|
+
# :ssl_cert_file => "cert.pem",
|
165
|
+
# :ssl_on_connect => true,
|
166
|
+
:operation_class => DirOperation,
|
167
|
+
:operation_args => [directory],
|
168
|
+
:schema => schema,
|
169
|
+
:namingContexts => ['dc=example,dc=com']
|
170
|
+
)
|
171
|
+
s.run_prefork
|
172
|
+
s.join
|