pvincent 8 hours ago
commit
79d31f61c4
  1. 43
      .github/workflows/ci.yml
  2. 4
      .gitignore
  3. 106
      CHANGELOG.md
  4. 27
      COPYING
  5. 4
      Gemfile
  6. 148
      README.md
  7. 15
      Rakefile
  8. 96
      examples/README
  9. 32
      examples/cert.pem
  10. 51
      examples/key.pem
  11. 17
      examples/ldapdb.yaml
  12. 34
      examples/mkcert.rb
  13. 112
      examples/rbslapd1.rb
  14. 161
      examples/rbslapd2.rb
  15. 11
      examples/rbslapd2.sql
  16. 172
      examples/rbslapd3.rb
  17. 90
      examples/rbslapd4.rb
  18. 73
      examples/rbslapd5.rb
  19. 75
      examples/rbslapd6.rb
  20. 37
      examples/speedtest.rb
  21. 4
      lib/ldap/server.rb
  22. 257
      lib/ldap/server/connection.rb
  23. 220
      lib/ldap/server/dn.rb
  24. 223
      lib/ldap/server/filter.rb
  25. 283
      lib/ldap/server/match.rb
  26. 528
      lib/ldap/server/operation.rb
  27. 92
      lib/ldap/server/preforkserver.rb
  28. 166
      lib/ldap/server/request.rb
  29. 71
      lib/ldap/server/result.rb
  30. 220
      lib/ldap/server/router.rb
  31. 592
      lib/ldap/server/schema.rb
  32. 123
      lib/ldap/server/server.rb
  33. 235
      lib/ldap/server/syntax.rb
  34. 102
      lib/ldap/server/tcpserver.rb
  35. 92
      lib/ldap/server/trie.rb
  36. 88
      lib/ldap/server/util.rb
  37. 5
      lib/ldap/server/version.rb
  38. 27
      ruby-ldapserver.gemspec
  39. 39
      spec/operation_spec.rb
  40. 0
      spec/spec_helper.rb
  41. 582
      test/core.schema
  42. 149
      test/dn_test.rb
  43. 289
      test/encoding_test.rb
  44. 107
      test/filter_test.rb
  45. 59
      test/match_test.rb
  46. 113
      test/schema_test.rb
  47. 40
      test/syntax_test.rb
  48. 2
      test/test_helper.rb
  49. 60
      test/trie_test.rb
  50. 51
      test/util_test.rb

43
.github/workflows/ci.yml

@ -0,0 +1,43 @@
name: CI
on: [push, pull_request]
jobs:
job_test_gem:
name: Test in source tree
strategy:
fail-fast: false
matrix:
include:
- os: windows
ruby: "head"
- os: windows
ruby: "2.4"
- os: ubuntu
ruby: "head"
- os: ubuntu
ruby: "3.1"
- os: ubuntu
ruby: "2.3"
- os: macos
ruby: "head"
runs-on: ${{ matrix.os }}-latest
steps:
- uses: actions/checkout@v3
- uses: ruby/setup-ruby@v1
with:
ruby-version: ${{ matrix.ruby }} # passed to ruby/setup-ruby
- name: Print tool versions
run: |
ruby -v
gem env
- name: Bundle install
run: bundle install
- name: Run tests
run: bundle exec rake

4
.gitignore

@ -0,0 +1,4 @@
Gemfile.lock
pkg
.idea
doc/

106
CHANGELOG.md

@ -0,0 +1,106 @@
## 0.7.0 / 2022-12-06
* Support optional attribute range retrieval according to
https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/searching-using-range-retrieval
* Add an experimental yet incomplete request router as alternative to using OperationClass
* Add support for listening on UNIX domain sockets.
Use `LDAP::Server.new(socket: '/tmp/server.sock')`
* Add LDAP::Server::DN to work with LDAP distinguished names
* Add CI on Github
* Use net-ldap for tests instead of unmaintained ruby-ldap.
## 0.5.3 / 2015-08-16
* Handle BN as client_timelimit; fixes incompatibility with some LDAP
implementations (notably Shibboleth IdP v2 and proftpd).
(Patch by Pete Birkinshaw.)
## 0.5.2 / 2015-06-24
* Make sure the exception used to stop the child doesn't propagate up (patch by Kasumi Hanazuki)
## 0.3.1 - 2008-01-16
* First release as a gem [Brandon Keepers]
## RELEASE_0_3
Filters now return nil instead of LDAP::Server::MatchingRule::DefaultMatch
in the case that there's no schema.
Minor changes to syntax.rb to support OpenLDAP extensions.
## 20050722
Change the 'validate' API so it works for updates too.
Change the 'modify' API so it sends a hash of attr=>[:op,data] which makes
it easier to determine which entries have been modified.
Fix modify, add and compare to normalise attribute names using the schema if
there is one.
## 20050721
Added a whole loada Schema stuff.
Moved exceptions under LDAP::ResultError for consistency with ruby-ldap.
Changed the parsed [filter] format to include a MatchingRule object always
(even if no schema is present)
## 20050711
Changed LDAPserver to LDAP::Server and rejigged the repository to match.
In your code you will have to change:
require 'ldapserver/foo' -> require 'ldap/server/foo'
LDAPserver::bar -> LDAP::Server::bar
I have added require 'ldap/server' which pulls in the things a basic server
will need (minus schema)
## 20050626
Factored out the SSL stuff into Connection, which should also allow the
STARTTLS extension to be implemented later
Added a Server class, with methods run_tcpserver and run_prefork.
Created an explicit preforkserver method.
## 20050625
tcpserver: add ability to drop privileges
examples/rbslapd3.rb: make work if ldapdb.yaml does not exist. Also bind
explicitly to 0.0.0.0; it seems that TCPSocket doesn't work properly in
some circumstances without it (FreeBSD 5.4 with IPv6 disabled in kernel)
## 20050620
RELEASE_0_2
Implemented SSL support in tcpserver, just by copying examples from
openssl module.
Tweak split_dn so that it should work properly with UTF-8 encoded strings
Added examples/rbslapd3.rb, a preforking LDAP server
Added :listen option to tcpserver to set listen queue size. With the default
of 5, and 100 children trying to connect, a few connections get dropped.
Added :nodelay option to tcpserver to set TCP_NODELAY socket option. This
removes 100ms of latency in responses.
Added examples/speedtest.rb
## 20050619
Modify connection.rb to ensure no memory leak in the event of exceptions
being raised in operation threads.
Fix examples/rbslapd2.rb SQLPool so that it always puts connections back
into the pool (using 'ensure' this time :-)
## 20050618
RELEASE_0_1
## 20050616

27
COPYING

@ -0,0 +1,27 @@
The file 'test/core.schema' is Copyrighted by the OpenLDAP project. See the
comments in that file for full details.
All other files in this distribution are subject to the following copyright
notice:
COPYING
=======
Copyright (c) 2005 Brian Candler
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.

4
Gemfile

@ -0,0 +1,4 @@
source 'https://rubygems.org'
# Specify your gem's dependencies in ldap-server-primitive.gemspec
gemspec

148
README.md

@ -0,0 +1,148 @@
# ruby-ldapserver
ruby-ldapserver is a lightweight, pure Ruby framework for implementing LDAP server applications. It is intended primarily for building a gateway from LDAP queries into some other protocol or database. It does not attempt to be a full or correct implementation of the standard LDAP data model itself (although you could build one using this as a frontend).
Since it's written entirely in Ruby, it benefits from Ruby's threading engine.
## Target audience
Technically-savvy Ruby applications developers; the sort of people who are happy to read RFCs and read code to work out what it does :-)
The examples/ directory contains a few minimal LDAP servers which you can use as a starting point.
## Status
This is still an early release. It works for me as an LDAP protocol layer; the Schema stuff has not been heavily tested.
## Request router
The request router is a simple mapping of potentially parameterized routes (DNs) and actions to a *controller* action, allowing for simple, flexible and maintainable code. Alternatively the legacy `Operation` class can be used. See the `examples/` directory for more details and sample implementations.
## Configuration
```ruby
params = {
# Bind to address (cannot be combined with socket)
bindaddr: '127.0.0.1', # defaults to 0.0.0.0
port: 1389,
# Bind to socket (cannot be combined with address)
socket: '/tmp/ldap.sock',
# Drop process and socket privileges to user and/or group (cannot be combined with uid/gid)
user: 'ldap',
group: 'ldap',
# Drop process and socket privileges to UID and/or GID (cannot be combined with user/group)
uid: 1000,
gid: 1000,
# TCP_NODELAY option
nodelay: true,
# Socket backlog
listen: 10,
# SSL/TLS
ssl_key_file: 'key.pem',
ssl_cert_file: 'cert.pem',
ssl_on_connect: true,
# Request router (cannot be combined with legacy operation)
router: MyAppRouter,
# Legacy Operation class (cannot be combined with request router)
operation_class: MyAppOperation,
operation_args: ['my', 'arguments'],
# Schema
schema: my_schema,
namingContexts: ['dc=example,dc=com'],
# Limit number of attribute values in query results
# https://learn.microsoft.com/en-us/previous-versions/windows/desktop/ldap/searching-using-range-retrieval
attribute_range_limit: 1500,
}
serv = LDAP::Server.new(params)
serv.run_tcpserver
```
## Libraries
ASN1 encoding and decoding is done using the 'openssl' extension, which is standard in the Ruby 1.8.2 base distribution. To check you have it, you should be able to run `ruby -ropenssl -e puts` with no error.
However, I've found in the past that Linux machines don't always build the openssl extension when compiling Ruby from source. With Red Hat 9, the solution for me was, when building Ruby itself:
```
$ export CPPFLAGS="-I/usr/kerberos/include"
$ export LDFLAGS="-L/usr/kerberos/lib"
$ ./configure ...etc
```
If you want to run the test suite then you'll need to install the `ruby-ldap` client library, and if you want to run `examples/rbslapd3.rb` then you'll need the `prefork` library. Both are available from <http://raa.ruby-lang.org/>.
## Protocol implementation
ruby-ldapserver tries to be a reasonably complete implementation of the message decoding and encoding components of LDAP. However, it does not synthesise or directly enforce the LDAP data model. It will advertise a schema in the root DSE if you configure one, and it provides helper functions which allow you to validate add and modify operations against a schema; but it's up to you to use them, if you wish. If you're just using LDAP as a convenient query interface into some other database, you probably don't care about schemas.
If your clients permit it, you can violate the LDAP specification further, eliminating some of the gross design flaws of LDAP. For example, you can ditch the LDAP idea that a Distinguished Name must consist of attr=val,attr=val,attr=val... and use whatever is convenient as a primary key (e.g. "val1,val2,val3" or "id,table_name"). The 'add' operation could allocate DNs automatically from a sequence. There's no need for the data duplication where an LDAP entry must contain the same attr=val pair which is also the entry's RDN. Violations of the LDAP spec in this way are at your own risk.
## Threading issues
The core of this library is the `LDAP::Server::Connection` object which handles communication with a single client, and the `LDAP::Server::Operation` object which handles a single request. Because the LDAP protocol allows a client to send multiple overlapping requests down the same TCP connection, I start a new Ruby thread for each Operation.
If your Operation object deals with any global shared data, then it needs to do so in a thread-safe way. If this is new to you then see
[http://www.rubycentral.com/book/tut_threads.html](http://www.rubycentral.com/book/tut_threads.html)
[http://www.rubygarden.org/ruby?MultiThreading](http://www.rubygarden.org/ruby?MultiThreading)
For incoming client connections, I have supplied a simple tcpserver method which starts a new Ruby thread for each client. This works fine, but in a multi-CPU system, all LDAP server operations will be processed on one CPU; also with a very large number of concurrent client connections, you may find you hit the a max-filedescriptors-per-process limit.
I have also provided a preforking server; see `examples/rbslapd3.rb`. In this case, your connections are handled in separate processes so they cannot share data directly in RAM.
If you are using the default threading tcpserver, then beware that a number of Ruby extension libraries block the threading interpreter. In particular, the client library `ruby-ldap` blocks when waiting for a response from a remote server, since it's a wrapper around a C library which is unaware of Ruby's threading engine. This can cause your application to 'freeze' periodically. Either choose client libraries which play well with threading, or make sure each client is handled in a different process.
For example, when talking to a MySQL database, you might want to choose `ruby-mysql` (which is a pure Ruby implementation of the MySQL protocol) rather than `mysql-ruby` (which is a wrapper around the C API, and blocks while waiting for responses from the server)
Even with something like `ruby-mysql`, beware DNS lookups: resolver libraries can block too. There is a pure Ruby resolver replacement in the standard library: if you do
```
require 'resolv-replace'
```
This changes TCPSocket and friends to use it instead of the default C resolver. Or you could just hard-code IP addresses, or put entries in /etc/hosts for the machines you want to contact.
Another threading issue to think about is abandoned and timed-out LDAP operations. The `Connection` object handles these by raising an `LDAP::Server::Abandon` or `LDAP::Server::TimeLimitExceeded` exception in the `Operation` thread, which you can either ignore or rescue. However, if in rescuing it you end up putting (say) a SQL connection back into a pool, you should beware that the SQL connection may still be mid-query, so it's probably better to discard it and use a fresh one next time.
## Performance
`examples/speedtest.rb` is a simple client which forks N processes, and in each process opens an LDAP connection, binds, and sends M search requests down it.
Using speedtest.rb and rbslapd1.rb, running on the *same* machine (single-processor AMD Athlon 2500+) I achieve around 800 searches per second with N=1,M=1000 and 300-400 searches per second with N=10,M=100.
## To-do list
- handle and test generation of LDAP referrals properly
- more cases in test suite: abandon, concurrency, performance tests, error
handling
- extensible match filters
- extended operations
RFC 2830 - Start TLS
RFC 3062 - password modify
RFC 2839 - whoami
RFC 3909 - cancel
## References
- [RFC2251](ftp://ftp.isi.edu/in-notes/rfc2251.txt) (base protocol)
- [RFC2252](ftp://ftp.isi.edu/in-notes/rfc2252.txt) (schema)
- [RFC2253](ftp://ftp.isi.edu/in-notes/rfc2253.txt) (DN encoding)
- [X.680](http://www.itu.int/ITU-T/studygroups/com17/languages/X.680-0207.pdf)
- [X.690](http://www.itu.int/ITU-T/studygroups/com10/languages/X.690_1297.pdf)
## Contact
You are very welcome to E-mail me with bug reports, patches, comments and suggestions for this software. However, please DON'T send me any general questions about LDAP, how LDAP works, how to apply LDAP in your particular situation, or questions about any other LDAP software. The [`ldap@umich.edu` mailing list](http://listserver.itd.umich.edu/cgi-bin/lyris.pl?enter=ldap) is probably the correct place to ask such questions.
Brian Candler <B.Candler@pobox.com>

15
Rakefile

@ -0,0 +1,15 @@
require "bundler/gem_tasks"
require 'rake/testtask'
Rake::TestTask.new do |t|
t.test_files = FileList['test/*_test.rb']
end
begin
require 'rspec/core/rake_task'
RSpec::Core::RakeTask.new(:spec)
rescue LoadError
task :spec
end
task default: [:test, :spec]

96
examples/README

@ -0,0 +1,96 @@
Using the example programs
==========================
These servers all listen on port 1389 by default, so that they don't have to
be run as root.
Example 1: trivial server using RAM hash
----------------------------------------
$ ruby rbslapd1.rb
In another window:
$ ldapadd -x -H ldap://127.0.0.1:1389/
dn: dc=example,dc=com
cn: Top object
dn: cn=Fred Flintstone,dc=example,dc=com
cn: Fred Flintstone
sn: Flintstone
mail: fred@bedrock.org
mail: fred.flintstone@bedrock.org
dn: cn=Wilma Flintstone,dc=example,dc=com
cn: Wilma Flintstone
mail: wilma@bedrock.org
^D
Try these queries:
$ ldapsearch -x -H ldap://127.0.0.1:1389/ -b "" "(objectclass=*)"
$ ldapsearch -x -H ldap://127.0.0.1:1389/ -b "dc=example,dc=com" -s base "(objectclass=*)"
$ ldapsearch -x -H ldap://127.0.0.1:1389/ -b "dc=example,dc=com" "(mail=fred*)"
If you terminate the server with Ctrl-C, its contents should be written
to disk as a YAML file.
A fairly complete set of the filter language is implemented. However, this
simple server works by simply scanning the entire database and applying the
filter to each entry, so it won't scale to large applications. No validation
of DN or attributes against any sort of schema is done.
Example 1a: with SSL
--------------------
In rbslapd1.rb, uncomment
:ssl_key_file => "key.pem",
:ssl_cert_file => "cert.pem",
:ssl_on_connect => true,
and run mkcert.rb. Since this is a self-signed certificate, you'll have to
turn off certificate verification in the client too. For example:
$ env LDAPTLS_REQCERT="allow" ldapsearch -x -H ldaps://127.0.0.1:1389/
Making your own CA and installing its certificate in the client, or
generating a Certificate Signing Request and sending it to a known CA, is
beyond the scope of this documentation.
Example 2: simple LDAP to SQL mapping
-------------------------------------
You will need to set up a MySQL database with a table conforming to the
schema given within the code. Once done, LDAP gives a read-only view of the
database with only the filter "(uid=<foo>)" supported.
Example 3: preforking server and schema
---------------------------------------
This functions in the same way as rbslapd1.rb. However, since each query is
answered in a separate process, the YAML file on disk is used as the master
repository. Update operations re-write this file each time.
Also, the schema is read from file 'core.schema'. Attempting to insert the
above entries will fail, due to schema violations. Insert a valid entry,
e.g.
dn: cn=Fred Flintstone,dc=example,dc=com
objectClass: organizationalPerson
cn: Fred Flintstone
sn: Flintstone
telephoneNumber: +1 555 1234
telephoneNumber: +1 555 5432
Schema validation takes place for the attribute values and that attributes
are allowed/required by the objectclass(es); however, the DN itself is not
validated, nor any checks made that the RDN is present as an attribute
(since this is one of the more stupid parts of the LDAP/X500 data model)
Example 4
------------
* ruby rbslapd4.rb
* `ldapwhoami -x -H ldap://127.0.0.1:1389 -D "uid=admin,ou=Users,dc=mydomain,dc=com" -w "adminpassword"`

32
examples/cert.pem

@ -0,0 +1,32 @@
-----BEGIN CERTIFICATE-----
MIIFhTCCA22gAwIBAgIBADANBgkqhkiG9w0BAQUFADAwMQswCQYDVQQGEwJKUDEN
MAsGA1UECgwEVEVTVDESMBAGA1UEAwwJbG9jYWxob3N0MB4XDTI2MDYwNTEwMTc1
MloXDTI2MDYwNTExMTc1MlowMDELMAkGA1UEBhMCSlAxDTALBgNVBAoMBFRFU1Qx
EjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC
ggIBALNUH3qAAd15GiX+69MxJPzLL58CgDvBUKZsMzoxlJZw27UQywcb7PM5uyir
DHX0f6+JbV5uDNQ3iNdnqVeQChd32C6OXMo5lzKCl7zLxrrdGrizgN63dcXjLzpA
WDv1NjVVc8eI/Bs1lMEjG+lZRN1yqF05yxjBo+IJbb5rHgcHoKclwxrzs9wrc1Ff
ZnM9dMbytEUrZgssq+NAmBm6ie7XGdTSwdANKUJqMD7IUht+dqoFM5TlpgFUBkyf
jRXpsBgBkab+87o6OZk9mKIBM09ITgJmKhjGiUZeQ6VLLPx5J+WBicPdmg6kfyXO
OpA7QReNZz3ODRyy2ksZ8upsNxsm9R1TeWLYGhfRuTnlBiq8x8EDB00vvhXO5anf
hwwJKiFjx7BI90VpfVd48KXSYI5p+fkXWjrteT7P2yN9Fxv6k70Mhy62FZQo+Q34
QWRsFYg4UrMWpPZUwtwqfXaeq/d9eBXvropotJASXHlFLUsHLjyG6Bmj/qfBMcJW
cti5nReDu4oIFr2xXND746Kx6zEwzR/6tAaimZ1BNYwqCCPxoaYpjC4WnXMiUVF8
78THoIzIrxW9H9ofE4y1bJQJ6F6KZAywOMoC/5dM8xfYePyIFpmR0k2pVKiTO/Yc
fyVWKLYk67jMg35e5zj91wmw0maVJZWNutVYnKUImfb5P+X5AgMBAAGjgakwgaYw
CQYDVR0TBAIwADAdBgNVHQ4EFgQUg2LjMxC59ERv/MY+n9WOtSPn/6gwEwYDVR0l
BAwwCgYIKwYBBQUHAwEwCwYDVR0PBAQDAgSwMFgGA1UdIwRRME+AFINi4zMQufRE
b/zGPp/VjrUj5/+ooTSkMjAwMQswCQYDVQQGEwJKUDENMAsGA1UECgwEVEVTVDES
MBAGA1UEAwwJbG9jYWxob3N0ggEAMA0GCSqGSIb3DQEBBQUAA4ICAQCYeNO7TZ64
QQUBPvu75sYhhOBwEtcQMR/lHNYoqYy0TA4W/E+wwiRPweaMkPyUkzkK2/NZXP7w
QB+gT4rMszN2fPZi6Bvr2M0QtO8/YVEzMPs5Y7XcJqL8TtRsUrNYUTLtLoZ4iebq
G7hsFNwQLAdSQ+/xd+LfcxGNjXmmErhbbR5B1WsVJ5tYmW4qTuYQpZF5lOqQKEfh
98ea9eJNTgWVk14Hk1cwsuZaH4IcDQgPhmu7fMvyunShk4a4ArDNRNE656w2uZch
ThViRVdh+bqew76PS5zQHlGNJDgySYxTIVLhlWwSoFHLZkal6W8IqkKfurFA9dbI
Qi15eoqxwApXFniq10Y1f82lOiW3IJNXPhEh7ch8mgOQ/uDppAf4HmkIy3CguMr3
F0pWVs8a7zliOw2Ejj8L2hLel64KUhmCQWfoD7gFdnj1jpZr2tlv/GBbpWhpt2X2
8Ok9bHiMXvmUFd/g8rG2XadkiFGwOTC0OguD/7tT8BmNRWDJb8Ga3dDnVzQ4xmV+
Q3M8f8jYxw0Hrs8cW9xyW+gSckeGLkKrtoqhS/DNaNzdk12MZ5wwSgyuvZjj9h84
wuF8+3KhE6ucdrr5cuqzAkaeGPe6fvEfkllSLt5s6gmN+6/bTg1/f1l5o/xWYlUH
Q34692xrylRsx0qRw37Rk525ihmim+LLlw==
-----END CERTIFICATE-----

51
examples/key.pem

@ -0,0 +1,51 @@
-----BEGIN RSA PRIVATE KEY-----
MIIJKAIBAAKCAgEAs1QfeoAB3XkaJf7r0zEk/MsvnwKAO8FQpmwzOjGUlnDbtRDL
Bxvs8zm7KKsMdfR/r4ltXm4M1DeI12epV5AKF3fYLo5cyjmXMoKXvMvGut0auLOA
3rd1xeMvOkBYO/U2NVVzx4j8GzWUwSMb6VlE3XKoXTnLGMGj4gltvmseBwegpyXD
GvOz3CtzUV9mcz10xvK0RStmCyyr40CYGbqJ7tcZ1NLB0A0pQmowPshSG352qgUz
lOWmAVQGTJ+NFemwGAGRpv7zujo5mT2YogEzT0hOAmYqGMaJRl5DpUss/Hkn5YGJ
w92aDqR/Jc46kDtBF41nPc4NHLLaSxny6mw3Gyb1HVN5YtgaF9G5OeUGKrzHwQMH
TS++Fc7lqd+HDAkqIWPHsEj3RWl9V3jwpdJgjmn5+RdaOu15Ps/bI30XG/qTvQyH
LrYVlCj5DfhBZGwViDhSsxak9lTC3Cp9dp6r9314Fe+uimi0kBJceUUtSwcuPIbo
GaP+p8ExwlZy2LmdF4O7iggWvbFc0PvjorHrMTDNH/q0BqKZnUE1jCoII/GhpimM
LhadcyJRUXzvxMegjMivFb0f2h8TjLVslAnoXopkDLA4ygL/l0zzF9h4/IgWmZHS
TalUqJM79hx/JVYotiTruMyDfl7nOP3XCbDSZpUllY261VicpQiZ9vk/5fkCAwEA
AQKCAgAXk8lZuUfFfx0VfskxqKX0yKAXt2P1t0prvxETJx6iku8IBM+0vRKvvdji
FW8beQxqn1ZuUmjEZBLNQ1dL6GezQzH8mQIRInZDEVFy5JXZzqUrQIqCfbtxy5dT
gM84/tnkNDp3Mwb2atqGdB/A09hOhzskmqsds6Pg3Z18qie2A+Y246uduQneOiY8
vh7BqwRV/eX+rCCL+pEU3VLCGzj0WnqOdTE/MePJVB3Iu1y0ObHPU8S+4FytkwcK
/vU1OtbIqTglrSKNSwd0otkU/7RnyZlcDmOdg0jcJBufuV0OSr2YmqnqwxF0uGLx
LQadHPVHa/N1eEhYDBnQQvahpJ2v7yIoz6IehWRuykdivrcSA/ELJVGuLB4yuO+B
sZvOvORXI/9M1mYJR67+Gm+L+AiDxpEa//EG0CCZBlzOzVsFxDxvpK59bK4mPF1i
uIH4xGliBTbZz2MFd8fGwa/xMrS5bHupfa8hNDdfj4uCxriYGSht/C6aFRLQ2Yce
JX2Xc7Eo6R6CFeTdRiwMv/B3ueC2VkdB79U4veeV6HVnzywrcdFj/+djYYFypkej
GfgxhnHjANEGu4S3Ebb6rTvboalUP+qXiyjbH0C9DjLzNYa2Cs1/Mt9/nfzlz2/A
yglZlkggnJwF44Z7DdeSOdVEsRYCbqzBSG7n7eiJfKhbrSbCWwKCAQEA6pf7f7iL
Boy7T8d5tEu0D+anN8m6RFzM4wsqr4/2MPeIyt+8meFw/JrtV1jfoQIx+PPN9FaJ
X6SoSXwxyaAzwz9ZvFjhOnyN5gbagHfE65onifq+ooBn40ErlT2hw2yXRHNswG7V
Z9jsJ8nRbR/qU9ojFHtiFX1vVeKsQCnTJGjBV3kMtOCGhvuMywTB5GcBLULY5grr
bF3RyIlzdDjFg18+iMFWe0Ny5OHuK812HyGQhoRc6M+KjyJucQdyyamqGzb1vSm+
jggp+h7VVunkfJ6h5dcM2zWQZdc7+JSCToagNBCYNL5fs3sC6Q5+UKJIFLxrcwRF
iTvD8n9+rFJ2DwKCAQEAw7ErdaH4JKdWdIoqIoCnsnpcBcZ3Cs1664UXH4U5gYer
w+20rVI9m83RFHxxhAYF1UkW26Yv8Ap6a4SCEvWDtn/Im4H6nYZeaXZKhsGEz34N
ugI9U4pkrShsdUKZExsBpfLwerW0k758cssJWMMAxdomDkSo1HYFltm9XlNzrmkR
0NQI7/NgehGOOsmV7+Smpdw+C8mMQZ4oIw1j5638V5DFgRwISmF/it3qEMHxDSSM
YLVVp5JD7sax6ChvelwXUXSeZFFlB9geMRAk10akmr79f7jOWJnz/rOd0VFAxgLi
ruyr37uJLAVHBNvXM3uNrC7+IJcWbv+vRWCXbAGrdwKCAQBtnBeBhJtIsyatzvkZ
eamnKFEHKvUiDe4ZQ1VtdClGldHPYJyBlakyDb1Ja5gJZbotpNSdDnXfP1L2CtZE
a9rjpkzqSOjrZ9jxGlCrZ8qVfpBs0sCRssdXklKnx4U2hx1ieT/d5atGez9UE+ML
Rrc4+JodbszUV6hWi7OJw0EJKPz1PvTl6mZQ2WXeUdm6Ozp8iFhJm96F4owrU7Wj
HweCK1VPlm4u58PeF4Yt5zECuK8LevriOF54JFFP9Hf4q5J0ZsiI2uFTAZODbzal
BmGgrInelw1FuxA91UQLEHCV+icOTJahRjX26Unh1MjGKhzdu2/E7MEEru0N9+4a
2+iXAoIBAE34SViVMElqYwgMBL26hRaXqhKjAMtNE6zDWnM0obT6WXW3QEXOfr2V
Q7jl3FS+EZTpijH6BR+fDSfJpAnpyJDuWP+cyj35S6S5fPg0IraJgu6Z9dVTTsmv
UYdnAZabLAzyvt4lh81WGD+kphS3nZc3U/JbaOk+HPv9xXXPykezlWWfFfCFB+ub
ExBZQWRTthJfrlkD9N4wJc3Rh/zHVcON6yOGB8ebETZDNP94RpL1/PiLR5V8sZRx
lnDpq4EVMDVEQde2loqJkX368LLVcsA1WMuK1qx2qsDQ0BCWTziV7bvEkLaUAhOI
BsPo09WvZMM19gsGJ+oR9cOuuKZQBAECggEBAN1AvO4tyhibAocnOOV0iO33G+li
nHIk4+W9cwWd5hxxoMS/2YWiUa4HqCDleBeRYwxXv0yayFqTUBPFSAE/+ZRn0ADj
jKs1nU1o49BHUPa7rIyc29x9FpPoNUIXVEFnJdQQxkJrPeXdm3VyAmfUuylDNuBs
ZRxiOqb3hvN6cbxDuN7bBfDrC4BJJZhICmSfvwZbvfbD3zwJpPtI2OfD8uij3mSd
advsYHl3ZLBv9d6KiqSEzS13PPhiTbyoVmB2M4VC1B+4W2tNCYgEzLROf8+GnuSJ
wvQOf2IX+QY9Q3gbSP9c/pbwHmC5ttvZQiA2VJW4XxpU8JMRwAn06p+BXb8=
-----END RSA PRIVATE KEY-----

17
examples/ldapdb.yaml

@ -0,0 +1,17 @@
---
dc=example,dc=com:
cn:
- Top object
cn=fred flintstone,dc=example,dc=com:
cn:
- Fred Flintstone
sn:
- Flintstone
mail:
- fred@bedrock.org
- fred.flintstone@bedrock.org
cn=wilma flintstone,dc=example,dc=com:
cn:
- Wilma Flintstone
mail:
- wilma@bedrock.org

34
examples/mkcert.rb

@ -0,0 +1,34 @@
require 'openssl'
# Taken directly from echo_svr.rb in the Ruby openssl examples
key = OpenSSL::PKey::RSA.new(4096) do
print '.'
$stdout.flush
end
puts
cert = OpenSSL::X509::Certificate.new
cert.version = 2
cert.serial = 0
name = OpenSSL::X509::Name.new([%w[C JP], %w[O TEST], %w[CN localhost]])
cert.subject = name
cert.issuer = name
cert.not_before = Time.now
cert.not_after = Time.now + 3600
cert.public_key = key.public_key
ef = OpenSSL::X509::ExtensionFactory.new(nil, cert)
cert.extensions = [
ef.create_extension('basicConstraints', 'CA:FALSE'),
ef.create_extension('subjectKeyIdentifier', 'hash'),
ef.create_extension('extendedKeyUsage', 'serverAuth'),
ef.create_extension('keyUsage',
'keyEncipherment,dataEncipherment,digitalSignature')
]
ef.issuer_certificate = cert
cert.add_extension ef.create_extension('authorityKeyIdentifier',
'keyid:always,issuer:always')
cert.sign(key, OpenSSL::Digest.new('SHA1'))
# Write to disk
File.open('key.pem', 'w', 0o600) { |f| f << key.to_pem }
File.open('cert.pem', 'w', 0o644) { |f| f << cert.to_pem }

112
examples/rbslapd1.rb

@ -0,0 +1,112 @@
#!/usr/local/bin/ruby -w
# This is a trivial LDAP server which just stores directory entries in RAM.
# It does no validation or authentication. This is intended just to
# demonstrate the API, it's not for real-world use!!
$:.unshift('../lib')
$debug = true
require 'ldap/server'
# We subclass the Operation class, overriding the methods to do what we need
class HashOperation < LDAP::Server::Operation
def initialize(connection, messageID, hash)
super(connection, messageID)
@hash = hash # an object reference to our directory data
end
def search(basedn, scope, deref, filter)
basedn = basedn.downcase
case scope
when LDAP::Server::BaseObject
# client asked for single object by DN
obj = @hash[basedn]
raise LDAP::ResultError::NoSuchObject unless obj
send_SearchResultEntry(basedn, obj) if LDAP::Server::Filter.run(filter, obj)
when LDAP::Server::WholeSubtree
@hash.each do |dn, av|
next unless dn.index(basedn, -basedn.length) # under basedn?
next unless LDAP::Server::Filter.run(filter, av) # attribute filter?
send_SearchResultEntry(dn, av)
end
else
raise LDAP::ResultError::UnwillingToPerform, "OneLevel not implemented"
end
end
def add(dn, av)
dn = dn.downcase
raise LDAP::ResultError::EntryAlreadyExists if @hash[dn]
@hash[dn] = av
end
def del(dn)
dn = dn.downcase
raise LDAP::ResultError::NoSuchObject unless @hash.has_key?(dn)
@hash.delete(dn)
end
def modify(dn, ops)
dn = dn.downcase
entry = @hash[dn]
raise LDAP::ResultError::NoSuchObject unless entry
ops.each do |attr, vals|
op = vals.shift
case op
when :add
entry[attr] ||= []
entry[attr] += vals
entry[attr].uniq!
when :delete
if vals == []
entry.delete(attr)
else
vals.each { |v| entry[attr].delete(v) }
end
when :replace
entry[attr] = vals
end
entry.delete(attr) if entry[attr] == []
end
end
end
# This is the shared object which carries our actual directory entries.
# It's just a hash of {dn=>entry}, where each entry is {attr=>[val,val,...]}
directory = {}
# Let's put some backing store on it
require 'yaml'
begin
File.open("ldapdb.yaml") { |f| directory = YAML::load(f.read) }
rescue Errno::ENOENT
end
at_exit do
File.open("ldapdb.new","w") { |f| f.write(YAML::dump(directory)) }
File.rename("ldapdb.new","ldapdb.yaml")
end
# Listen for incoming LDAP connections. For each one, create a Connection
# object, which will invoke a HashOperation object for each request.
s = LDAP::Server.new(
:port => 1389,
:nodelay => true,
:listen => 10,
# :ssl_key_file => "key.pem",
# :ssl_cert_file => "cert.pem",
# :ssl_on_connect => true,
:operation_class => HashOperation,
:operation_args => [directory]
)
s.run_tcpserver
s.join

161
examples/rbslapd2.rb

@ -0,0 +1,161 @@
#!/usr/local/bin/ruby -w
$:.unshift('../lib')
require 'ldap/server'
require 'mysql' # <http://www.tmtm.org/en/ruby/mysql/>
require 'thread'
require 'resolv-replace' # ruby threading DNS client
# An example of an LDAP to SQL gateway. We have a MySQL table which
# contains (login_id,login,passwd) combinations, e.g.
#
# +----------+----------+--------+
# | login_id | login | passwd |
# +----------+----------+--------+
# | 1 | brian | foobar |
# | 2 | caroline | boing |
# +----------+----------+--------+
#
# We support LDAP searches for (uid=login), returning a synthesised DN and
# Maildir attribute, and we support LDAP binds to validate passwords. We
# keep a cache of recent lookups so that a bind to validate a password
# doesn't cause a second SQL query. Since we're multi-threaded, this should
# work even if the bind occurs on a different client connection to the search.
#
# To test:
# ldapsearch -x -H ldap://127.0.0.1:1389/ -b "dc=example,dc=com" "(uid=brian)"
#
# ldapsearch -x -H ldap://127.0.0.1:1389/ -b "dc=example,dc=com" \
# -D "id=1,dc=example,dc=com" -W "(uid=brian)"
$debug = true
SQL_CONNECT = ["1.2.3.4", "myuser", "mypass", "mydb"]
TABLE = "logins"
SQL_POOL_SIZE = 5
PW_CACHE_SIZE = 100
BASEDN = "dc=example,dc=com"
LDAP_PORT = 1389
# A thread-safe pool of persistent MySQL connections
class SQLPool
def initialize(n, *args)
@args = args
@pool = Queue.new # this is a thread-safe queue
n.times { @pool.push nil } # create connections on demand
end
def borrow
conn = @pool.pop || Mysql::new(*@args)
yield conn
rescue Exception
conn = nil # put 'nil' back into the pool
raise
ensure
@pool.push conn
end
end
# An simple LRU cache of username->password. It's linearly searched
# so don't make it too big.
class LRUCache
def initialize(size)
@size = size
@cache = [] # [[key,val],[key,val],...]
@mutex = Mutex.new
end
def add(id,data)
@mutex.synchronize do
@cache.delete_if { |k,v| k == id }
@cache.unshift [id,data]
@cache.pop while @cache.size > @size
end
end
def find(id)
@mutex.synchronize do
index = entry = nil
@cache.each_with_index do |e, i|
if e[0] == id
entry = e
index = i
break
end
end
return nil unless index
@cache.delete_at(index)
@cache.unshift entry
return entry[1]
end
end
end
class SQLOperation < LDAP::Server::Operation
def self.setcache(cache,pool)
@@cache = cache
@@pool = pool
end
# Handle searches of the form "(uid=<foo>)" using SQL backend
# (uid=foo) => [:eq, "uid", matchobj, "foo"]
def search(basedn, scope, deref, filter)
raise LDAP::ResultError::UnwillingToPerform, "Bad base DN" unless basedn == BASEDN
raise LDAP::ResultError::UnwillingToPerform, "Bad filter" unless filter[0..1] == [:eq, "uid"]
uid = filter[3]
@@pool.borrow do |sql|
q = "select login_id,passwd from #{TABLE} where login='#{sql.quote(uid)}'"
puts "SQL Query #{sql.object_id}: #{q}" if $debug
res = sql.query(q)
res.each do |login_id,passwd|
@@cache.add(login_id, passwd)
send_SearchResultEntry("id=#{login_id},#{BASEDN}", {
"maildir"=>["/netapp/#{uid}/"],
})
end
end
end
# Validate passwords
def simple_bind(version, dn, password)
return if dn.nil? # accept anonymous
raise LDAP::ResultError::UnwillingToPerform unless dn =~ /\Aid=(\d+),#{BASEDN}\z/
login_id = $1
dbpw = @@cache.find(login_id)
unless dbpw
@@pool.borrow do |sql|
q = "select passwd from #{TABLE} where login_id=#{login_id}"
puts "SQL Query #{sql.object_id}: #{q}" if $debug
res = sql.query(q)
if res.num_rows == 1
dbpw = res.fetch_row[0]
@@cache.add(login_id, dbpw)
end
end
end
raise LDAP::ResultError::InvalidCredentials unless dbpw and dbpw != "" and dbpw == password
end
end
# Build the objects we need
cache = LRUCache.new(PW_CACHE_SIZE)
pool = SQLPool.new(SQL_POOL_SIZE, *SQL_CONNECT)
SQLOperation.setcache(cache,pool)
s = LDAP::Server.new(
:port => LDAP_PORT,
:nodelay => true,
:listen => 10,
# :ssl_key_file => "key.pem",
# :ssl_cert_file => "cert.pem",
# :ssl_on_connect => true,
:operation_class => SQLOperation
)
s.run_tcpserver
s.join

11
examples/rbslapd2.sql

@ -0,0 +1,11 @@
CREATE TABLE logins (
login_id MEDIUMINT NOT NULL AUTO_INCREMENT,
login CHAR(30) NOT NULL,
passwd CHAR(30),
PRIMARY KEY (login_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8;
INSERT INTO logins(login, passwd) VALUES
('brian', 'foobar'), ('caroline', 'boing');
SELECT * FROM logins;

172
examples/rbslapd3.rb

@ -0,0 +1,172 @@
#!/usr/local/bin/ruby -w
# This is similar to rbslapd1.rb but here we use TOMITA Masahiro's prefork
# library: <http://raa.ruby-lang.org/project/prefork/>
# Advantages over Ruby threading:
# - each client connection is handled in its own process; don't need
# to worry about Ruby thread blocking (except if one client issues
# overlapping LDAP operations down the same connection, which is uncommon)
# - better scalability on multi-processor systems
# - better scalability on single-processor systems (e.g. shouldn't hit
# max FDs per process limit)
# Disadvantages:
# - client connections can't share state in RAM. So our shared directory
# now has to be read from disk, and flushed to disk after every update.
#
# Additionally, I have added schema support. An LDAP v3 client can
# query the schema remotely, and adds/modifies have data validated.
$:.unshift('../lib')
require 'ldap/server'
require 'ldap/server/schema'
require 'yaml'
$debug = nil # $stderr
# An object to keep our in-RAM database and synchronise it to disk
# when necessary
class Directory
attr_reader :data
def initialize(filename)
@filename = filename
@stat = nil
update
end
# synchronise with directory on disk (re-read if it has changed)
def update
begin
tmp = {}
sb = File.stat(@filename)
return if @stat and @stat.ino == sb.ino and @stat.mtime == sb.mtime
File.open(@filename) do |f|
tmp = YAML::load(f.read)
@stat = f.stat
end
rescue Errno::ENOENT
end
@data = tmp
end
# write back to disk
def write
File.open(@filename+".new","w") { |f| f.write(YAML::dump(@data)) }
File.rename(@filename+".new",@filename)
@stat = File.stat(@filename)
end
# run a block while holding a lock on the database
def lock
File.open(@filename+".lock","w") do |f|
f.flock(File::LOCK_EX) # will block here until lock available
yield
end
end
end
# We subclass the Operation class, overriding the methods to do what we need
class DirOperation < LDAP::Server::Operation
def initialize(connection, messageID, dir)
super(connection, messageID)
@dir = dir
end
def search(basedn, scope, deref, filter)
$debug << "Search: basedn=#{basedn.inspect}, scope=#{scope.inspect}, deref=#{deref.inspect}, filter=#{filter.inspect}\n" if $debug
basedn = basedn.downcase
case scope
when LDAP::Server::BaseObject
# client asked for single object by DN
@dir.update
obj = @dir.data[basedn]
raise LDAP::ResultError::NoSuchObject unless obj
ok = LDAP::Server::Filter.run(filter, obj)
$debug << "Match=#{ok.inspect}: #{obj.inspect}\n" if $debug
send_SearchResultEntry(basedn, obj) if ok
when LDAP::Server::WholeSubtree
@dir.update
@dir.data.each do |dn, av|
$debug << "Considering #{dn}\n" if $debug
next unless dn.index(basedn, -basedn.length) # under basedn?
next unless LDAP::Server::Filter.run(filter, av) # attribute filter?
$debug << "Sending: #{av.inspect}\n" if $debug
send_SearchResultEntry(dn, av)
end
else
raise LDAP::ResultError::UnwillingToPerform, "OneLevel not implemented"
end
end
def add(dn, entry)
entry = @schema.validate(entry)
entry['createTimestamp'] = [Time.now.gmtime.strftime("%Y%m%d%H%MZ")]
entry['creatorsName'] = [@connection.binddn.to_s]
# FIXME: normalize the DN and check it's below our root DN
# FIXME: validate that a superior object exists
# FIXME: validate that entry contains the RDN attribute (yuk)
dn = dn.downcase
@dir.lock do
@dir.update
raise LDAP::ResultError::EntryAlreadyExists if @dir.data[dn]
@dir.data[dn] = entry
@dir.write
end
end
def del(dn)
dn = dn.downcase
@dir.lock do
@dir.update
raise LDAP::ResultError::NoSuchObject unless @dir.data.has_key?(dn)
@dir.data.delete(dn)
@dir.write
end
end
def modify(dn, ops)
dn = dn.downcase
@dir.lock do
@dir.update
entry = @dir.data[dn]
raise LDAP::ResultError::NoSuchObject unless entry
entry = @schema.validate(ops, entry) # also does the update
entry['modifyTimestamp'] = [Time.now.gmtime.strftime("%Y%m%d%H%MZ")]
entry['modifiersName'] = [@connection.binddn.to_s]
@dir.data[dn] = entry
@dir.write
end
end
end
directory = Directory.new("ldapdb.yaml")
schema = LDAP::Server::Schema.new
schema.load_system
schema.load_file("../test/core.schema")
schema.resolve_oids
s = LDAP::Server.new(
:port => 1389,
:nodelay => true,
:listen => 10,
# :ssl_key_file => "key.pem",
# :ssl_cert_file => "cert.pem",
# :ssl_on_connect => true,
:operation_class => DirOperation,
:operation_args => [directory],
:schema => schema,
:namingContexts => ['dc=example,dc=com']
)
s.run_prefork
s.join

90
examples/rbslapd4.rb

@ -0,0 +1,90 @@
#!/usr/local/bin/ruby -w
# This is a modified version of rbslapd1.rb which uses a Router instead of
# subclassing the LDAP::Server::Operation class.
# This is a trivial LDAP server which just stores directory entries in RAM.
# It does no validation or authentication. This is intended just to
# demonstrate the API, it's not for real-world use!!
$:.unshift('../lib')
$debug = true
require 'ldap/server'
require 'ldap/server/router'
$logger = Logger.new($stderr)
class LDAPController
def self.bind(request, version, dn, password, params)
$logger.debug "Catchall bind request"
raise LDAP::ResultError::UnwillingToPerform, "Invalid bind DN"
end
def self.bindUser(request, version, dn, password, params)
if params[:uid].nil? or
params[:uid] != 'admin' or
password != 'adminpassword'
$logger.warn "Denied access for user #{params[:uid]}: Invalid credentials"
raise LDAP::ResultError::InvalidCredentials, "Invalid credentials"
end
$logger.info "Authenticated user #{params[:uid]}"
end
def self.search(request, baseObject, scope, deref, filter, params)
$logger.info "Catchall search request for #{baseObject}"
raise LDAP::ResultError::UnwillingToPerform, "Invalid search DN"
end
def self.searchUsers(request, baseObject, scope, deref, filter, params)
$logger.info "Search users"
end
end
router = LDAP::Server::Router.new($logger) do
# Different syntax but same thing
bind nil => "LDAPController#bind"
route :bind, nil => "LDAPController#bind"
# Bind a route using variables. A hash with the variables will be passed
# to your function as last argument.
bind "uid=:uid,ou=Users,dc=mydomain,dc=com" => "LDAPController#bindUser"
search nil => "LDAPController#search"
search "ou=Users,dc=mydomain,dc=com" => "LDAPController#searchUsers"
end
# This is the shared object which carries our actual directory entries.
# It's just a hash of {dn=>entry}, where each entry is {attr=>[val,val,...]}
directory = {}
# Let's put some backing store on it
require 'yaml'
begin
File.open("ldapdb.yaml") { |f| directory = YAML::load(f.read) }
rescue Errno::ENOENT
end
at_exit do
File.open("ldapdb.new","w") { |f| f.write(YAML::dump(directory)) }
File.rename("ldapdb.new","ldapdb.yaml")
end
# Listen for incoming LDAP connections. For each one, create a Connection
# object, which will invoke a HashOperation object for each request.
s = LDAP::Server.new(
:port => 1389,
:nodelay => true,
:listen => 10,
# :ssl_key_file => "key.pem",
# :ssl_cert_file => "cert.pem",
# :ssl_on_connect => true,
:router => router
)
s.run_tcpserver
s.join

73
examples/rbslapd5.rb

@ -0,0 +1,73 @@
#!/usr/local/bin/ruby -w
# Example server that listens on both a port and a UNIX domain socket
# Try it using:
# $ ldapsearch -LLL -H ldap://localhost:1389 -D uid=whatever -b ou=Users,dc=mydomain,dc=com
# $ ldapsearch -LLL -H ldapi://%2ftmp%2frbslapd5.sock -D uid=whatever -b ou=Users,dc=mydomain,dc=com
$:.unshift('../lib')
$debug = true
require 'fileutils'
require 'ldap/server'
require 'ldap/server/router'
$logger = Logger.new($stderr)
class LDAPController
def self.bind(request, version, dn, password, params)
$logger.info "Processing bind route for \'#{dn}\' with password \'#{password}\'"
end
def self.search(request, baseObject, scope, deref, filter, params)
$logger.info "Processing search route for #{baseObject}"
h = {
'uid' => 'jdoe',
'objectClass' => 'userAccount',
'givenName' => 'John',
'sn' => 'Doe'
}
request.send_SearchResultEntry("uid=jdoe,#{baseObject}", h)
end
end
router = LDAP::Server::Router.new($logger) do
bind nil => "LDAPController#bind"
search "ou=Users,dc=mydomain,dc=com" => "LDAPController#search"
end
params = {
:nodelay => true,
:listen => 10,
:router => router
}
# Listen on IP address and port
params[:bindaddr] = '127.0.0.1' # Leave this blank to listen on 0.0.0.0
params[:port] = 1389
addr_server = LDAP::Server.new params
addr_server.run_tcpserver
# Listen on socket
params.delete :bindaddr
params.delete :port
params[:socket] = '/tmp/rbslapd5.sock'
FileUtils::rm_f params[:socket]
socket_server = LDAP::Server.new params
socket_server.run_tcpserver
trap 'INT' do
addr_server.stop
socket_server.stop
end
addr_server.join
socket_server.join

75
examples/rbslapd6.rb

@ -0,0 +1,75 @@
#!/usr/local/bin/ruby -w
# Slightly modified version of rbslapd5.rb which demonstrates dropping
# root privileges after binding to port 389
#
# Run this script with `sudo`
$:.unshift('../lib')
$debug = true
require 'fileutils'
require 'ldap/server'
require 'ldap/server/router'
$logger = Logger.new($stderr)
class LDAPController
def self.bind(request, version, dn, password, params)
$logger.info "Processing bind route for \'#{dn}\' with password \'#{password}\'"
end
def self.search(request, baseObject, scope, deref, filter, params)
$logger.info "Processing search route for #{baseObject}"
h = {
'uid' => 'jdoe',
'objectClass' => 'userAccount',
'givenName' => 'John',
'sn' => 'Doe'
}
request.send_SearchResultEntry("uid=jdoe,#{baseObject}", h)
end
end
router = LDAP::Server::Router.new($logger) do
bind nil => "LDAPController#bind"
search "ou=Users,dc=mydomain,dc=com" => "LDAPController#search"
end
params = {
:nodelay => true,
:listen => 10,
:router => router
}
# Listen on IP address and port
params[:bindaddr] = '127.0.0.1' # Leave this blank to listen on 0.0.0.0
params[:port] = 389
params[:user] = 'ldap'
params[:group] = 'ldap'
addr_server = LDAP::Server.new params
addr_server.run_tcpserver
# Listen on socket
params.delete :bindaddr
params.delete :port
params[:socket] = '/tmp/rbslapd6.sock'
FileUtils::rm_f params[:socket]
socket_server = LDAP::Server.new params
socket_server.run_tcpserver
trap 'INT' do
addr_server.stop
socket_server.stop
end
addr_server.join
socket_server.join

37
examples/speedtest.rb

@ -0,0 +1,37 @@
#!/usr/local/bin/ruby
require 'ldap'
CHILDREN = 10
CONNECTS = 1 # per child
SEARCHES = 100 # per connection
pids = []
CHILDREN.times do
pids << fork do
CONNECTS.times do
conn = LDAP::Conn.new("localhost",1389)
conn.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 3)
conn.bind
SEARCHES.times do
res = conn.search("cn=Fred Flintstone,dc=example,dc=com", LDAP::LDAP_SCOPE_BASE,
"(objectclass=*)") do |e|
#puts "#{$$} #{e.dn.inspect}"
end
end
conn.unbind
end
end
end
okcount = 0
badcount = 0
pids.each do |p|
Process.wait(p)
if $?.exitstatus == 0
okcount += 1
else
badcount += 1
end
end
puts "Children finished: #{okcount} ok, #{badcount} failed"
exit badcount > 0 ? 1 : 0

4
lib/ldap/server.rb

@ -0,0 +1,4 @@
require 'ldap/server/result'
require 'ldap/server/connection'
require 'ldap/server/operation'
require 'ldap/server/server'

257
lib/ldap/server/connection.rb

@ -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
lib/ldap/server/dn.rb

@ -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
lib/ldap/server/filter.rb

@ -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
lib/ldap/server/match.rb

@ -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
lib/ldap/server/operation.rb

@ -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
lib/ldap/server/preforkserver.rb

@ -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
lib/ldap/server/request.rb

@ -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
lib/ldap/server/result.rb

@ -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
lib/ldap/server/router.rb

@ -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
lib/ldap/server/schema.rb

@ -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
lib/ldap/server/server.rb

@ -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
lib/ldap/server/syntax.rb

@ -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
lib/ldap/server/tcpserver.rb

@ -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
lib/ldap/server/trie.rb

@ -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
lib/ldap/server/util.rb

@ -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
lib/ldap/server/version.rb

@ -0,0 +1,5 @@
module LDAP #:nodoc:
class Server #:nodoc:
VERSION = '0.7.0'
end
end

27
ruby-ldapserver.gemspec

@ -0,0 +1,27 @@
# -*- encoding: utf-8 -*-
lib = File.expand_path('../lib', __FILE__)
$LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
require 'ldap/server/version'
Gem::Specification.new do |s|
s.name = %q{ruby-ldapserver}
s.version = LDAP::Server::VERSION
s.authors = ["Brian Candler", "Florian Dejonckheere", "Lars Kanis"]
s.description = %q{ruby-ldapserver is a lightweight, pure-Ruby skeleton for implementing LDAP server applications.}
s.email = ["B.Candler@pobox.com", "florian@floriandejonckheere.be", "lars@greiz-reinsdorf.de"]
s.files = `git ls-files`.split($/)
s.homepage = %q{https://github.com/larskanis/ruby-ldapserver}
s.rdoc_options = ["--main", "README.md"]
s.require_paths = ["lib"]
s.summary = %q{A pure-Ruby framework for building LDAP servers}
s.test_files = s.files.grep(%r{^(test|spec|features)/})
s.required_ruby_version = '>= 2.3'
s.add_development_dependency 'bundler', '>= 1.3', '< 3.0'
s.add_development_dependency 'rake', '~> 13.0'
s.add_development_dependency 'net-ldap', '~> 0.10'
s.add_development_dependency 'rspec', '~> 3.1'
s.add_development_dependency 'test-unit', '~> 3.0'
end

39
spec/operation_spec.rb

@ -0,0 +1,39 @@
require 'spec_helper'
require 'ldap/server/operation'
describe LDAP::Server::Operation do
let(:server) { double 'server' }
let(:connection) { double "connection", opt: { schema: schema, server: server } }
let(:message_id) { 337 }
subject(:operation) { LDAP::Server::Operation.new connection, message_id }
context 'on search' do
before do
operation.instance_variable_set :@attributes, attributes
operation.instance_variable_set :@rescount, 0
end
context 'with schema and wildcard attribute query' do
let(:schema) do
double('schema').tap do |schema|
allow(schema).to receive(:find_attrtype).and_return nil
allow(schema).to receive(:find_attrtype).with('attr')\
.and_return double 'attr', usage: nil
end
end
let(:attributes) { %w(*) }
describe '#send_SearchResultEntry' do
it 'correctly handles wildcard attribute' do
expect(connection).to receive(:write).twice do |message|
expect(message).to include 'val'
end
operation.send_SearchResultEntry 'o=foo', 'attr' => 'val'
operation.send_SearchResultEntry 'o=bar', 'attr' => 'val'
end
end
end
end
end

0
spec/spec_helper.rb

582
test/core.schema

@ -0,0 +1,582 @@
# OpenLDAP Core schema
# $OpenLDAP: pkg/ldap/servers/slapd/schema/core.schema,v 1.68.2.6 2005/01/20 17:01:18 kurt Exp $
## This work is part of OpenLDAP Software <http://www.openldap.org/>.
##
## Copyright 1998-2005 The OpenLDAP Foundation.
## All rights reserved.
##
## Redistribution and use in source and binary forms, with or without
## modification, are permitted only as authorized by the OpenLDAP
## Public License.
##
## A copy of this license is available in the file LICENSE in the
## top-level directory of the distribution or, alternatively, at
## <http://www.OpenLDAP.org/license.html>.
#
## Portions Copyright (C) The Internet Society (1997-2003).
## All Rights Reserved.
##
## This document and translations of it may be copied and furnished to
## others, and derivative works that comment on or otherwise explain it
## or assist in its implementation may be prepared, copied, published
## and distributed, in whole or in part, without restriction of any
## kind, provided that the above copyright notice and this paragraph are
## included on all such copies and derivative works. However, this
## document itself may not be modified in any way, such as by removing
## the copyright notice or references to the Internet Society or other
## Internet organizations, except as needed for the purpose of
## developing Internet standards in which case the procedures for
## copyrights defined in the Internet Standards process must be
## followed, or as required to translate it into languages other than
## English.
##
## The limited permissions granted above are perpetual and will not be
## revoked by the Internet Society or its successors or assigns.
##
## This document and the information contained herein is provided on an
## "AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING
## TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
## BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION
## HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED WARRANTIES OF
## MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
#
#
# Includes LDAPv3 schema items from:
# RFC 2252/2256 (LDAPv3)
#
# Select standard track schema items:
# RFC 1274 (uid/dc)
# RFC 2079 (URI)
# RFC 2247 (dc/dcObject)
# RFC 2587 (PKI)
# RFC 2589 (Dynamic Directory Services)
#
# Select informational schema items:
# RFC 2377 (uidObject)
#
# Standard attribute types from RFC 2256
#
# system schema
#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 )
# system schema
#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 ( 2.5.4.2 NAME 'knowledgeInformation'
DESC 'RFC2256: knowledge information'
EQUALITY caseIgnoreMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
# system schema
#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.4 NAME ( 'sn' 'surname' )
DESC 'RFC2256: last (family) name(s) for which the entity is known by'
SUP name )
attributetype ( 2.5.4.5 NAME 'serialNumber'
DESC 'RFC2256: serial number of the entity'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{64} )
attributetype ( 2.5.4.6 NAME ( 'c' 'countryName' )
DESC 'RFC2256: ISO-3166 country 2-letter code'
EQUALITY caseIgnoreMatch SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.11
SINGLE-VALUE )
attributetype ( 2.5.4.7 NAME ( 'l' 'localityName' )
DESC 'RFC2256: locality which this object resides in'
SUP name )
attributetype ( 2.5.4.8 NAME ( 'st' 'stateOrProvinceName' )
DESC 'RFC2256: state or province which this object resides in'
SUP name )
attributetype ( 2.5.4.9 NAME ( 'street' 'streetAddress' )
DESC 'RFC2256: street address of this object'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )
attributetype ( 2.5.4.10 NAME ( 'o' 'organizationName' )
DESC 'RFC2256: organization this object belongs to'
SUP name )
attributetype ( 2.5.4.11 NAME ( 'ou' 'organizationalUnitName' )
DESC 'RFC2256: organizational unit this object belongs to'
SUP name )
attributetype ( 2.5.4.12 NAME 'title'
DESC 'RFC2256: title associated with the entity'
SUP name )
attributetype ( 2.5.4.13 NAME 'description'
DESC 'RFC2256: descriptive information'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{1024} )
# Obsoleted by enhancedSearchGuide
attributetype ( 2.5.4.14 NAME 'searchGuide'
DESC 'RFC2256: search guide, obsoleted by enhancedSearchGuide'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.25 )
attributetype ( 2.5.4.15 NAME 'businessCategory'
DESC 'RFC2256: business category'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )
attributetype ( 2.5.4.16 NAME 'postalAddress'
DESC 'RFC2256: postal address'
EQUALITY caseIgnoreListMatch
SUBSTR caseIgnoreListSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 )
attributetype ( 2.5.4.17 NAME 'postalCode'
DESC 'RFC2256: postal code'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{40} )
attributetype ( 2.5.4.18 NAME 'postOfficeBox'
DESC 'RFC2256: Post Office Box'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{40} )
attributetype ( 2.5.4.19 NAME 'physicalDeliveryOfficeName'
DESC 'RFC2256: Physical Delivery Office Name'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{128} )
attributetype ( 2.5.4.20 NAME 'telephoneNumber'
DESC 'RFC2256: Telephone Number'
EQUALITY telephoneNumberMatch
SUBSTR telephoneNumberSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.50{32} )
attributetype ( 2.5.4.21 NAME 'telexNumber'
DESC 'RFC2256: Telex Number'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.52 )
attributetype ( 2.5.4.22 NAME 'teletexTerminalIdentifier'
DESC 'RFC2256: Teletex Terminal Identifier'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.51 )
attributetype ( 2.5.4.23 NAME ( 'facsimileTelephoneNumber' 'fax' )
DESC 'RFC2256: Facsimile (Fax) Telephone Number'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.22 )
attributetype ( 2.5.4.24 NAME 'x121Address'
DESC 'RFC2256: X.121 Address'
EQUALITY numericStringMatch
SUBSTR numericStringSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.36{15} )
attributetype ( 2.5.4.25 NAME 'internationaliSDNNumber'
DESC 'RFC2256: international ISDN number'
EQUALITY numericStringMatch
SUBSTR numericStringSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.36{16} )
attributetype ( 2.5.4.26 NAME 'registeredAddress'
DESC 'RFC2256: registered postal address'
SUP postalAddress
SYNTAX 1.3.6.1.4.1.1466.115.121.1.41 )
attributetype ( 2.5.4.27 NAME 'destinationIndicator'
DESC 'RFC2256: destination indicator'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.44{128} )
attributetype ( 2.5.4.28 NAME 'preferredDeliveryMethod'
DESC 'RFC2256: preferred delivery method'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.14
SINGLE-VALUE )
attributetype ( 2.5.4.29 NAME 'presentationAddress'
DESC 'RFC2256: presentation address'
EQUALITY presentationAddressMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.43
SINGLE-VALUE )
attributetype ( 2.5.4.30 NAME 'supportedApplicationContext'
DESC 'RFC2256: supported application context'
EQUALITY objectIdentifierMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.38 )
attributetype ( 2.5.4.31 NAME 'member'
DESC 'RFC2256: member of a group'
SUP distinguishedName )
attributetype ( 2.5.4.32 NAME 'owner'
DESC 'RFC2256: owner (of the object)'
SUP distinguishedName )
attributetype ( 2.5.4.33 NAME 'roleOccupant'
DESC 'RFC2256: occupant of role'
SUP distinguishedName )
attributetype ( 2.5.4.34 NAME 'seeAlso'
DESC 'RFC2256: DN of related object'
SUP distinguishedName )
# system schema
#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} )
# Must be transferred using ;binary
# with certificateExactMatch rule (per X.509)
attributetype ( 2.5.4.36 NAME 'userCertificate'
DESC 'RFC2256: X.509 user certificate, use ;binary'
EQUALITY certificateExactMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.8 )
# Must be transferred using ;binary
# with certificateExactMatch rule (per X.509)
attributetype ( 2.5.4.37 NAME 'cACertificate'
DESC 'RFC2256: X.509 CA certificate, use ;binary'
EQUALITY certificateExactMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.8 )
# Must be transferred using ;binary
attributetype ( 2.5.4.38 NAME 'authorityRevocationList'
DESC 'RFC2256: X.509 authority revocation list, use ;binary'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 )
# Must be transferred using ;binary
attributetype ( 2.5.4.39 NAME 'certificateRevocationList'
DESC 'RFC2256: X.509 certificate revocation list, use ;binary'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 )
# Must be stored and requested in the binary form
attributetype ( 2.5.4.40 NAME 'crossCertificatePair'
DESC 'RFC2256: X.509 cross certificate pair, use ;binary'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.10 )
# 2.5.4.41 is defined above as it's used for subtyping
#attributetype ( 2.5.4.41 NAME 'name'
# EQUALITY caseIgnoreMatch
# SUBSTR caseIgnoreSubstringsMatch
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
attributetype ( 2.5.4.42 NAME ( 'givenName' 'gn' )
DESC 'RFC2256: first name(s) for which the entity is known by'
SUP name )
attributetype ( 2.5.4.43 NAME 'initials'
DESC 'RFC2256: initials of some or all of names, but not the surname(s).'
SUP name )
attributetype ( 2.5.4.44 NAME 'generationQualifier'
DESC 'RFC2256: name qualifier indicating a generation'
SUP name )
attributetype ( 2.5.4.45 NAME 'x500UniqueIdentifier'
DESC 'RFC2256: X.500 unique identifier'
EQUALITY bitStringMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.6 )
attributetype ( 2.5.4.46 NAME 'dnQualifier'
DESC 'RFC2256: DN qualifier'
EQUALITY caseIgnoreMatch
ORDERING caseIgnoreOrderingMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.44 )
attributetype ( 2.5.4.47 NAME 'enhancedSearchGuide'
DESC 'RFC2256: enhanced search guide'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.21 )
attributetype ( 2.5.4.48 NAME 'protocolInformation'
DESC 'RFC2256: protocol information'
EQUALITY protocolInformationMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.42 )
# 2.5.4.49 is defined above as it's used for subtyping
#attributetype ( 2.5.4.49 NAME 'distinguishedName'
# EQUALITY distinguishedNameMatch
# SYNTAX 1.3.6.1.4.1.1466.115.121.1.12 )
attributetype ( 2.5.4.50 NAME 'uniqueMember'
DESC 'RFC2256: unique member of a group'
EQUALITY uniqueMemberMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.34 )
attributetype ( 2.5.4.51 NAME 'houseIdentifier'
DESC 'RFC2256: house identifier'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{32768} )
# Must be transferred using ;binary
attributetype ( 2.5.4.52 NAME 'supportedAlgorithms'
DESC 'RFC2256: supported algorithms'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.49 )
# Must be transferred using ;binary
attributetype ( 2.5.4.53 NAME 'deltaRevocationList'
DESC 'RFC2256: delta revocation list; use ;binary'
SYNTAX 1.3.6.1.4.1.1466.115.121.1.9 )
attributetype ( 2.5.4.54 NAME 'dmdName'
DESC 'RFC2256: name of DMD'
SUP name )
# Standard object classes from RFC2256
# system schema
#objectclass ( 2.5.6.1 NAME 'alias'
# DESC 'RFC2256: an alias'
# SUP top STRUCTURAL
# MUST aliasedObjectName )
objectclass ( 2.5.6.2 NAME 'country'
DESC 'RFC2256: a country'
SUP top STRUCTURAL
MUST c
MAY ( searchGuide $ description ) )
objectclass ( 2.5.6.3 NAME 'locality'
DESC 'RFC2256: a locality'
SUP top STRUCTURAL
MAY ( street $ seeAlso $ searchGuide $ st $ l $ description ) )
objectclass ( 2.5.6.4 NAME 'organization'
DESC 'RFC2256: an organization'
SUP top STRUCTURAL
MUST o
MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $
x121Address $ registeredAddress $ destinationIndicator $
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
telephoneNumber $ internationaliSDNNumber $
facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $
postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) )
objectclass ( 2.5.6.5 NAME 'organizationalUnit'
DESC 'RFC2256: an organizational unit'
SUP top STRUCTURAL
MUST ou
MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $
x121Address $ registeredAddress $ destinationIndicator $
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
telephoneNumber $ internationaliSDNNumber $
facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $
postalAddress $ physicalDeliveryOfficeName $ st $ l $ description ) )
objectclass ( 2.5.6.6 NAME 'person'
DESC 'RFC2256: a person'
SUP top STRUCTURAL
MUST ( sn $ cn )
MAY ( userPassword $ telephoneNumber $ seeAlso $ description ) )
objectclass ( 2.5.6.7 NAME 'organizationalPerson'
DESC 'RFC2256: an organizational person'
SUP person STRUCTURAL
MAY ( title $ x121Address $ registeredAddress $ destinationIndicator $
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
telephoneNumber $ internationaliSDNNumber $
facsimileTelephoneNumber $ street $ postOfficeBox $ postalCode $
postalAddress $ physicalDeliveryOfficeName $ ou $ st $ l ) )
objectclass ( 2.5.6.8 NAME 'organizationalRole'
DESC 'RFC2256: an organizational role'
SUP top STRUCTURAL
MUST cn
MAY ( x121Address $ registeredAddress $ destinationIndicator $
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $
seeAlso $ roleOccupant $ preferredDeliveryMethod $ street $
postOfficeBox $ postalCode $ postalAddress $
physicalDeliveryOfficeName $ ou $ st $ l $ description ) )
objectclass ( 2.5.6.9 NAME 'groupOfNames'
DESC 'RFC2256: a group of names (DNs)'
SUP top STRUCTURAL
MUST ( member $ cn )
MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) )
objectclass ( 2.5.6.10 NAME 'residentialPerson'
DESC 'RFC2256: an residential person'
SUP person STRUCTURAL
MUST l
MAY ( businessCategory $ x121Address $ registeredAddress $
destinationIndicator $ preferredDeliveryMethod $ telexNumber $
teletexTerminalIdentifier $ telephoneNumber $ internationaliSDNNumber $
facsimileTelephoneNumber $ preferredDeliveryMethod $ street $
postOfficeBox $ postalCode $ postalAddress $
physicalDeliveryOfficeName $ st $ l ) )
objectclass ( 2.5.6.11 NAME 'applicationProcess'
DESC 'RFC2256: an application process'
SUP top STRUCTURAL
MUST cn
MAY ( seeAlso $ ou $ l $ description ) )
objectclass ( 2.5.6.12 NAME 'applicationEntity'
DESC 'RFC2256: an application entity'
SUP top STRUCTURAL
MUST ( presentationAddress $ cn )
MAY ( supportedApplicationContext $ seeAlso $ ou $ o $ l $
description ) )
objectclass ( 2.5.6.13 NAME 'dSA'
DESC 'RFC2256: a directory system agent (a server)'
SUP applicationEntity STRUCTURAL
MAY knowledgeInformation )
objectclass ( 2.5.6.14 NAME 'device'
DESC 'RFC2256: a device'
SUP top STRUCTURAL
MUST cn
MAY ( serialNumber $ seeAlso $ owner $ ou $ o $ l $ description ) )
objectclass ( 2.5.6.15 NAME 'strongAuthenticationUser'
DESC 'RFC2256: a strong authentication user'
SUP top AUXILIARY
MUST userCertificate )
objectclass ( 2.5.6.16 NAME 'certificationAuthority'
DESC 'RFC2256: a certificate authority'
SUP top AUXILIARY
MUST ( authorityRevocationList $ certificateRevocationList $
cACertificate ) MAY crossCertificatePair )
objectclass ( 2.5.6.17 NAME 'groupOfUniqueNames'
DESC 'RFC2256: a group of unique names (DN and Unique Identifier)'
SUP top STRUCTURAL
MUST ( uniqueMember $ cn )
MAY ( businessCategory $ seeAlso $ owner $ ou $ o $ description ) )
objectclass ( 2.5.6.18 NAME 'userSecurityInformation'
DESC 'RFC2256: a user security information'
SUP top AUXILIARY
MAY ( supportedAlgorithms ) )
objectclass ( 2.5.6.16.2 NAME 'certificationAuthority-V2'
SUP certificationAuthority
AUXILIARY MAY ( deltaRevocationList ) )
objectclass ( 2.5.6.19 NAME 'cRLDistributionPoint'
SUP top STRUCTURAL
MUST ( cn )
MAY ( certificateRevocationList $ authorityRevocationList $
deltaRevocationList ) )
objectclass ( 2.5.6.20 NAME 'dmd'
SUP top STRUCTURAL
MUST ( dmdName )
MAY ( userPassword $ searchGuide $ seeAlso $ businessCategory $
x121Address $ registeredAddress $ destinationIndicator $
preferredDeliveryMethod $ telexNumber $ teletexTerminalIdentifier $
telephoneNumber $ internationaliSDNNumber $ facsimileTelephoneNumber $
street $ postOfficeBox $ postalCode $ postalAddress $
physicalDeliveryOfficeName $ st $ l $ description ) )
#
# Object Classes from RFC 2587
#
objectclass ( 2.5.6.21 NAME 'pkiUser'
DESC 'RFC2587: a PKI user'
SUP top AUXILIARY
MAY userCertificate )
objectclass ( 2.5.6.22 NAME 'pkiCA'
DESC 'RFC2587: PKI certificate authority'
SUP top AUXILIARY
MAY ( authorityRevocationList $ certificateRevocationList $
cACertificate $ crossCertificatePair ) )
objectclass ( 2.5.6.23 NAME 'deltaCRL'
DESC 'RFC2587: PKI user'
SUP top AUXILIARY
MAY deltaRevocationList )
#
# Standard Track URI label schema from RFC 2079
# system schema
#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 )
objectclass ( 1.3.6.1.4.1.250.3.15 NAME 'labeledURIObject'
DESC 'RFC2079: object that contains the URI attribute type'
SUP top AUXILIARY
MAY labeledURI )
#
# Derived from RFC 1274, but with new "short names"
#
attributetype ( 0.9.2342.19200300.100.1.1
NAME ( 'uid' 'userid' )
DESC 'RFC1274: user identifier'
EQUALITY caseIgnoreMatch
SUBSTR caseIgnoreSubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.15{256} )
attributetype ( 0.9.2342.19200300.100.1.3
NAME ( 'mail' 'rfc822Mailbox' )
DESC 'RFC1274: RFC822 Mailbox'
EQUALITY caseIgnoreIA5Match
SUBSTR caseIgnoreIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{256} )
objectclass ( 0.9.2342.19200300.100.4.19 NAME 'simpleSecurityObject'
DESC 'RFC1274: simple security object'
SUP top AUXILIARY
MUST userPassword )
# RFC 1274 + RFC 2247
attributetype ( 0.9.2342.19200300.100.1.25
NAME ( 'dc' 'domainComponent' )
DESC 'RFC1274/2247: domain component'
EQUALITY caseIgnoreIA5Match
SUBSTR caseIgnoreIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 SINGLE-VALUE )
# RFC 2247
objectclass ( 1.3.6.1.4.1.1466.344 NAME 'dcObject'
DESC 'RFC2247: domain component object'
SUP top AUXILIARY MUST dc )
# RFC 2377
objectclass ( 1.3.6.1.1.3.1 NAME 'uidObject'
DESC 'RFC2377: uid object'
SUP top AUXILIARY MUST uid )
# From COSINE Pilot
attributetype ( 0.9.2342.19200300.100.1.37
NAME 'associatedDomain'
DESC 'RFC1274: domain associated with object'
EQUALITY caseIgnoreIA5Match
SUBSTR caseIgnoreIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )
# RFC 2459 -- deprecated in favor of 'mail' (in cosine.schema)
attributetype ( 1.2.840.113549.1.9.1
NAME ( 'email' 'emailAddress' 'pkcs9email' )
DESC 'RFC2459: legacy attribute for email addresses in DNs'
EQUALITY caseIgnoreIA5Match
SUBSTR caseIgnoreIA5SubstringsMatch
SYNTAX 1.3.6.1.4.1.1466.115.121.1.26{128} )

149
test/dn_test.rb

@ -0,0 +1,149 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/dn'
class TestLdapDn < Test::Unit::TestCase
def setup
@dn = LDAP::Server::DN.new("cn=Steve Kille,o=Isode Limited,o=Companies,c=GB")
end
def test_find_first
assert_equal(nil, @dn.find_first("ou"))
assert_equal("Steve Kille", @dn.find_first("cn"))
assert_equal("Isode Limited", @dn.find_first("o"))
end
def test_find_last
assert_equal(nil, @dn.find_last("ou"))
assert_equal("Steve Kille", @dn.find_last("cn"))
assert_equal("Companies", @dn.find_last("o"))
end
def test_find
assert_equal([], @dn.find("ou"))
assert_equal(["Steve Kille"], @dn.find("cn"))
assert_equal(["Isode Limited", "Companies"], @dn.find("o"))
end
def test_find_nth
assert_equal(nil, @dn.find_nth("ou", 0))
assert_equal("Steve Kille", @dn.find_nth("cn", 0))
assert_equal(nil, @dn.find_nth("cn", 1))
assert_equal("Isode Limited", @dn.find_nth("o", 0))
assert_equal("Companies", @dn.find_nth("o", 1))
assert_equal(nil, @dn.find_nth("o", 2))
end
def test_start_with
assert @dn.start_with?("cn=Steve Kille")
assert @dn.start_with?("cn=Steve Kille, o=Isode Limited")
refute @dn.start_with?("cn=John Doe")
end
def test_start_with_format
assert @dn.start_with_format?("cn=Steve Kille")
assert @dn.start_with_format?("cn=foo, o=bar")
refute @dn.start_with_format?("c=GB")
refute @dn.start_with_format?("c=BE")
end
def test_end_with
assert @dn.end_with?("c=GB")
assert @dn.end_with?("o=Companies, c=GB")
refute @dn.end_with?("c=BE")
end
def test_end_with_format
assert @dn.end_with_format?("c=GB")
assert @dn.end_with_format?("o=foo, c=bar")
refute @dn.end_with_format?("cn=Steve Kille")
refute @dn.end_with_format?("cn=foo")
end
def test_equal
assert @dn.equal?("CN=Steve Kille, o=Isode Limited,O=Companies,c=GB")
refute @dn.equal?("cn=John Doe,o=Isode Limited,o=Companies,c=GB")
end
def test_equal_format
assert @dn.equal_format?("cn=Steve Kille,o=Isode Limited,o=Companies,c=GB")
assert @dn.equal_format?("cn=STEVE KILLE,o=ISODE LIMITED,o=COMPANIES,c=GB")
assert @dn.equal_format?("cn=foo,o=bar,o=baz,c=bat")
assert @dn.equal_format?("CN=foo,O=bar,O=baz,C=bat")
refute @dn.equal_format?("cn=foo,o=Isode Limited,c=GB")
end
def test_include
assert @dn.include?("cn=Steve Kille,o=Isode Limited,o=Companies,c=GB")
assert @dn.include?("cn=Steve Kille")
assert @dn.include?("o=Isode Limited")
assert @dn.include?("o=Isode Limited,o=Companies")
assert @dn.include?("c=GB")
refute @dn.include?("cn=John Doe,o=Isode Limited,o=Companies,c=GB")
refute @dn.include?("cn=Steve Kille,o=Isode Limited,c=GB")
end
def test_include_format
assert @dn.include_format?("cn=foo,o=bar,o=baz,c=bat")
assert @dn.include_format?("cn=foo")
assert @dn.include_format?("o=bar")
assert @dn.include_format?("o=bar,o=baz")
assert @dn.include_format?("c=bat")
refute @dn.include_format?("cn=foo,o=bar,c=bat")
refute @dn.include_format?("cn=bar,c=bat")
end
def test_parse
assert_equal @dn.parse("cn=:cn,o=:company,o=Companies,c=:country"),
{
:cn => 'Steve Kille',
:company => 'Isode Limited',
:country => 'GB'
}
assert_equal @dn.parse("cn=:cn,o=:company,o=:company,c=:country"),
{
:cn => 'Steve Kille',
:company => 'Companies',
:country => 'GB'
}
assert_empty @dn.parse("cn=Steve Kille,o=Isode Limited,o=Companies,c=GB")
assert_equal @dn.parse("cn=:cn,cn=Steve Kille,o=Isode Limited,o=Companies,c=GB"),
{
:cn => nil
}
assert_empty @dn.parse("o=Foo,o=Companies,c=GB")
assert_empty @dn.parse("cn=:cn,o=Foo,o=Companies,c=GB")
end
def test_each
answer = [
{ 'cn' => 'Steve Kille' },
{ 'o' => 'Isode Limited' },
{ 'o' => 'Companies' },
{ 'c' => 'GB' }
]
i = 0
@dn.each do |pair|
assert_equal pair, answer[i]
i += 1
end
end
def test_reverse_each
answer = [
{ 'c' => 'GB' },
{ 'o' => 'Companies' },
{ 'o' => 'Isode Limited' },
{ 'cn' => 'Steve Kille' }
]
i = 0
@dn.reverse_each do |pair|
assert_equal pair, answer[i]
i += 1
end
end
end

289
test/encoding_test.rb

@ -0,0 +1,289 @@
require File.dirname(__FILE__) + '/test_helper'
Thread.abort_on_exception = true
# This test suite requires the ruby-ldap client library to be installed.
#
# Unfortunately, this library is not ruby thread-safe (it blocks until
# it gets a response). Hence we have to fork a child to perform the actual
# LDAP requests, which is nasty. However, it does give us a completely
# independent source of LDAP packets to try.
require 'ldap/server/operation'
require 'ldap/server/server'
require 'net/ldap'
# We subclass the Operation class, overriding the methods to do what we need
class MockOperation < LDAP::Server::Operation
def initialize(connection, messageId)
super(connection, messageId)
@@lastop = [:connect]
end
def simple_bind(version, user, pass)
@@lastop = [:simple_bind, version.to_i, user, pass]
end
def search(basedn, scope, deref, filter)
@@lastop = [:search, basedn, scope.to_i, deref.to_i, filter, @attributes]
send_SearchResultEntry("cn=foo", {"a"=>["1","2"], "b"=>"boing"})
send_SearchResultEntry("cn=bar", {"a"=>["3","4","5"], "b"=>"wibble"})
end
def add(dn, av)
@@lastop = [:add, dn, av]
end
def del(dn)
@@lastop = [:del, dn]
end
def modify(dn, ops)
@@lastop = [:modify, dn, ops]
end
def modifydn(dn, newrdn, deleteoldrdn, newSuperior)
@@lastop = [:modifydn, dn, newrdn, deleteoldrdn, newSuperior]
end
def compare(dn, attr, val)
@@lastop = [:compare, dn, attr, val]
return val != "false"
end
def self.lastop
@@lastop
end
end
class TestLdap < Test::Unit::TestCase
HOST = '127.0.0.1'
PORT = 1389
def start_client
in_ = Queue.new
out = Queue.new
Thread.new do
do_child(in_, out)
end
return in_, out
end
def ensure_server_started
@serv || start_server
end
def start_server(opts={})
# back to a single process (the parent). Now we start our
# listener thread
@serv = LDAP::Server.new({
:bindaddr => '127.0.0.1',
:port => PORT,
:nodelay => true,
:operation_class => MockOperation,
}.merge(opts))
@serv.run_tcpserver
end
def setup
@client = nil
@serv = nil
end
def teardown
if @serv
@serv.stop
@serv = nil
end
if @client
# @client.close
@client = nil
end
end
def conn
ensure_server_started
@client ||= Net::LDAP.new(host: HOST, port: PORT)
end
def test_bind2
pend("net-ldap gem doesn't support protocol 2")
# TODO: Net::LDAP only supports protocol 3
conn.set_option(LDAP::LDAP_OPT_PROTOCOL_VERSION, 2)
conn.auth("foo","bar")
conn.bind
assert_equal([:simple_bind, 2, "foo", "bar"], MockOperation.lastop)
# cannot bind any more; ldap client library says "already binded." (sic)
end
def test_bind3
conn.auth("foo","bar")
conn.bind
assert_equal([:simple_bind, 3, "foo", "bar"], MockOperation.lastop)
# cannot bind any more; ldap client library says "already binded." (sic)
end
def test_add
entry1 = {
objectclass: ['top', 'domain'],
o: ['TTSKY.NET'],
dc: ['localhost'],
}
conn.add(dn: "dc=localhost, dc=domain", attributes: entry1)
assert_equal([:add, "dc=localhost, dc=domain", {
'objectclass'=>['top', 'domain'],
'o'=>['TTSKY.NET'],
'dc'=>['localhost'],
}], MockOperation.lastop)
entry2 = {
objectclass: ['top', 'person'],
cn: ['Takaaki Tateishi'],
sn: ['ttate','Tateishi', "zero\000zero"],
}
conn.add(dn: "cn=Takaaki Tateishi, dc=localhost, dc=localdomain", attributes: entry2)
assert_equal([:add, "cn=Takaaki Tateishi, dc=localhost, dc=localdomain", {
'objectclass'=>['top', 'person'],
'cn'=>['Takaaki Tateishi'],
'sn'=>['ttate','Tateishi',"zero\000zero"],
}], MockOperation.lastop)
end
def test_del
conn.delete(dn: "cn=Takaaki-Tateishi, dc=localhost, dc=localdomain")
assert_equal([:del, "cn=Takaaki-Tateishi, dc=localhost, dc=localdomain"], MockOperation.lastop)
end
def test_compare
pend("net-ldap gem doesn't support compare requests")
res = conn.compare("cn=Takaaki Tateishi, dc=localhost, dc=localdomain",
"cn", "Takaaki Tateishi")
assert_equal([:compare, "cn=Takaaki Tateishi, dc=localhost, dc=localdomain",
"cn", "Takaaki Tateishi"], MockOperation.lastop)
assert res
res = conn.compare("cn=Takaaki Tateishi, dc=localhost, dc=localdomain",
"cn", "false")
assert_equal([:compare, "cn=Takaaki Tateishi, dc=localhost, dc=localdomain",
"cn", "false"], MockOperation.lastop)
refute res
end
def test_modrdn
conn.modify_rdn(olddn: "cn=Takaaki Tateishi, dc=localhost, dc=localdomain",
newrdn: "cn=Takaaki-Tateishi",
delete_attributes: true)
assert_equal([:modifydn, "cn=Takaaki Tateishi, dc=localhost, dc=localdomain",
"cn=Takaaki-Tateishi", true, nil], MockOperation.lastop)
# FIXME: ruby-ldap doesn't support the four-argument form
end
def test_modify
entry = [
[:add, :objectclass, ['top', 'domain']],
[:delete, :o, []],
[:replace, :dc, ['localhost']],
]
conn.modify(dn: "dc=localhost, dc=domain", operations: entry)
assert_equal([:modify, "dc=localhost, dc=domain", {
'objectclass' => [:add, 'top', 'domain'],
'o' => [:delete],
'dc' => [:replace, 'localhost'],
}], MockOperation.lastop)
end
def test_search
res = []
conn.search(base: "dc=localhost, dc=localdomain",
scope: Net::LDAP::SearchScope_WholeSubtree,
filter: "(objectclass=*)") do |e|
res << e.to_h
end
assert_equal([:search, "dc=localhost, dc=localdomain",
LDAP::Server::WholeSubtree,
LDAP::Server::NeverDerefAliases,
[:true], []], MockOperation.lastop)
exp = [
{a: ["1","2"], b: ["boing"], dn: ["cn=foo"]},
{a: ["3","4","5"], b: ["wibble"], dn: ["cn=bar"]},
]
assert_equal exp, res
res = []
# FIXME: ruby-ldap doesn't seem to allow DEREF options to be set
conn.search(base: "dc=localhost, dc=localdomain",
scope: Net::LDAP::SearchScope_BaseObject,
filter: "(&(cn=foo)(objectclass=*)(|(!(sn=*))(ou>=baz)(o<=z)(cn=*and*er)))",
attributes: [:a, :b]) do |e|
res << e.to_h
end
assert_equal([:search, "dc=localhost, dc=localdomain",
LDAP::Server::BaseObject,
LDAP::Server::NeverDerefAliases,
[:and, [:eq, "cn", nil, "foo"],
[:or, [:not, [:present, "sn"]],
[:ge, "ou", nil, "baz"],
[:le, "o", nil, "z"],
[:substrings, "cn", nil, nil, "and", "er"],
],
], ["a","b"]], MockOperation.lastop)
assert_equal exp, res
end
def test_search_with_range
res = []
conn.search(base: "dc=localhost, dc=localdomain",
scope: Net::LDAP::SearchScope_BaseObject,
attributes: ["a;range=1-2", "b"]) do |e|
res << e.to_h
end
assert_equal([:search, "dc=localhost, dc=localdomain",
LDAP::Server::BaseObject,
LDAP::Server::NeverDerefAliases,
[:true], ["a","b"]], MockOperation.lastop)
exp = [
{a: [], "a;range=1-*": ["2"], b: ["boing"], dn: ["cn=foo"]},
{a: [], "a;range=1-2": ["4","5"], b: ["wibble"], dn: ["cn=bar"]},
]
assert_equal exp, res
end
def test_search_with_range_limit
start_server(attribute_range_limit: 2)
res = []
conn.search(base: "dc=localhost, dc=localdomain",
scope: Net::LDAP::SearchScope_WholeSubtree,
filter: "(objectclass=*)") do |e|
res << e.to_h
end
assert_equal([:search, "dc=localhost, dc=localdomain",
LDAP::Server::WholeSubtree,
LDAP::Server::NeverDerefAliases,
[:true], []], MockOperation.lastop)
exp = [
{a: ["1","2"], b: ["boing"], dn: ["cn=foo"]},
{a: [], "a;range=0-1": ["3","4"], b: ["wibble"], dn: ["cn=bar"]},
]
assert_equal exp, res
end
end

107
test/filter_test.rb

@ -0,0 +1,107 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/filter'
class FilterTest < Test::Unit::TestCase
AV1 = {
"foo" => ["abc","def"],
"bar" => ["wibblespong"],
}
def test_bad
assert_raises(LDAP::ResultError::OperationsError) {
LDAP::Server::Filter.run([:wibbly], AV1)
}
end
def test_const
assert_equal(true, LDAP::Server::Filter.run([:true], AV1))
assert_equal(false, LDAP::Server::Filter.run([:false], AV1))
assert_equal(nil, LDAP::Server::Filter.run([:undef], AV1))
end
def test_present
assert_equal(true, LDAP::Server::Filter.run([:present,"foo"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:present,"zog"], AV1))
end
def test_eq
assert_equal(true, LDAP::Server::Filter.run([:eq,"foo",nil,"abc"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:eq,"foo",nil,"def"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:eq,"foo",nil,"ghi"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:eq,"xyz",nil,"abc"], AV1))
end
def test_eq_case
c = LDAP::Server::MatchingRule.find('2.5.13.2')
assert_equal(true, LDAP::Server::Filter.run([:eq,"foo",c,"ABC"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:eq,"foo",c,"DeF"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:eq,"foo",c,"ghi"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:eq,"xyz",c,"abc"], AV1))
end
def test_not
assert_equal(false, LDAP::Server::Filter.run([:not,[:eq,"foo",nil,"abc"]], AV1))
assert_equal(false, LDAP::Server::Filter.run([:not,[:eq,"foo",nil,"def"]], AV1))
assert_equal(true, LDAP::Server::Filter.run([:not,[:eq,"foo",nil,"ghi"]], AV1))
assert_equal(true, LDAP::Server::Filter.run([:not,[:eq,"xyz",nil,"abc"]], AV1))
end
def test_ge
assert_equal(true, LDAP::Server::Filter.run([:ge,"foo",nil,"ccc"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:ge,"foo",nil,"def"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:ge,"foo",nil,"deg"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:ge,"xyz",nil,"abc"], AV1))
end
def test_le
assert_equal(true, LDAP::Server::Filter.run([:le,"foo",nil,"ccc"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:le,"foo",nil,"abc"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:le,"foo",nil,"abb"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:le,"xyz",nil,"abc"], AV1))
end
def test_substrings
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,"a",nil], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,"def",nil], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"foo",nil,"bc",nil], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"foo",nil,"az",nil], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,"",nil], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"zzz",nil,"",nil], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"a",nil], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"e",nil], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"ba",nil], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"az",nil], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"c"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"ef"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"ab"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"foo",nil,nil,"e"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"bar",nil,"wib","ong"], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"bar",nil,"",""], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"bar",nil,"wib","ble"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"bar",nil,"sp","ong"], AV1))
end
def test_substr_case
c = LDAP::Server::MatchingRule.find('1.3.6.1.4.1.1466.109.114.3')
assert_equal(true, LDAP::Server::Filter.run([:substrings,"bar",c,"WIB",nil], AV1))
assert_equal(true, LDAP::Server::Filter.run([:substrings,"bar",c,"WIB","lES","ong"], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"bar",c,"SPONG",nil], AV1))
assert_equal(false, LDAP::Server::Filter.run([:substrings,"xyz",c,"wib",nil], AV1))
end
def test_and
assert_equal(true, LDAP::Server::Filter.run([:and,[:true],[:true]], AV1))
assert_equal(false, LDAP::Server::Filter.run([:and,[:false],[:true]], AV1))
assert_equal(false, LDAP::Server::Filter.run([:and,[:true],[:false]], AV1))
assert_equal(false, LDAP::Server::Filter.run([:and,[:false],[:false]], AV1))
end
def test_or
assert_equal(true, LDAP::Server::Filter.run([:or,[:true],[:true]], AV1))
assert_equal(true, LDAP::Server::Filter.run([:or,[:false],[:true]], AV1))
assert_equal(true, LDAP::Server::Filter.run([:or,[:true],[:false]], AV1))
assert_equal(false, LDAP::Server::Filter.run([:or,[:false],[:false]], AV1))
end
end

59
test/match_test.rb

@ -0,0 +1,59 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/match'
class MatchTest < Test::Unit::TestCase
def test_caseIgnoreOrderingMatch
s = LDAP::Server::MatchingRule.find("2.5.13.3")
assert_equal(LDAP::Server::MatchingRule, s.class)
assert_equal("caseIgnoreOrderingMatch", s.name)
assert_equal("( 2.5.13.3 NAME 'caseIgnoreOrderingMatch' SYNTAX 1.3.6.1.4.1.1466.115.121.1.15 )", s.to_def)
assert_equal(true, s.le(["foobar","wibble"], "ghi"))
assert_equal(true, s.le(["FOOBAR","WIBBLE"], "ghi"))
assert_equal(true, s.le(["foobar","wibble"], "GHI"))
assert_equal(true, s.le(["FOOBAR","WIBBLE"], "GHI"))
assert_equal(false, s.le(["foobar","wibble"], "fab"))
assert_equal(false, s.le(["FOOBAR","WIBBLE"], "fab"))
assert_equal(false, s.le(["foobar","wibble"], "FAB"))
assert_equal(false, s.le(["FOOBAR","WIBBLE"], "FAB"))
end
def test_caseIgnoreSubstringsMatch
s = LDAP::Server::MatchingRule.find("2.5.13.4")
assert_equal(LDAP::Server::MatchingRule, s.class)
assert_equal("caseIgnoreSubstringsMatch", s.name)
assert_equal(true, s.substrings(["foobar","wibble"], nil, "oob", nil))
assert_equal(true, s.substrings(["foobar","wibble"], nil, "foo", nil))
assert_equal(true, s.substrings(["foobar","wibble"], nil, "bar", nil))
assert_equal(true, s.substrings(["foobar","wibble"], "wib", nil))
assert_equal(true, s.substrings(["foobar","wibble"], nil, "ar"))
assert_equal(true, s.substrings(["foobar","wibble"], "wib", "ble"))
assert_equal(true, s.substrings(["foobar","wibble"], nil, "oo", "bar"))
assert_equal(false, s.substrings(["foobar","wibble"], nil, "ooz", nil))
assert_equal(false, s.substrings(["foobar","wibble"], nil, "foz", nil))
assert_equal(false, s.substrings(["foobar","wibble"], nil, "zar", nil))
assert_equal(false, s.substrings(["foobar","wibble"], "bar", nil))
assert_equal(false, s.substrings(["foobar","wibble"], nil, "oob"))
assert_equal(false, s.substrings(["foobar","wibble"], "foo", "ble"))
assert_equal(false, s.substrings(["foobar","wibble"], "foo", "obar"))
end
def test_unknown
s = LDAP::Server::MatchingRule.find("1.4.7.1")
assert_equal(nil, s) ## this may change to generate a default object
end
def test_nil
s = LDAP::Server::MatchingRule.find(nil)
assert_equal(nil, s)
end
def test_from_def
s = LDAP::Server::MatchingRule.from_def("( 1.2.3 NAME ( 'wibble' 'bibble' ) DESC 'foobar' SYNTAX 1.3.6.1.4.1.1466.115.121.1.26 )")
assert_equal("1.2.3", s.oid)
assert_equal(['wibble','bibble'], s.names)
assert_equal('wibble', s.to_s)
assert_equal("foobar", s.desc)
assert_equal("IA5 String", s.syntax.desc)
end
end

113
test/schema_test.rb

@ -0,0 +1,113 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/schema'
require 'ldap/server/match'
class SchemaTest < Test::Unit::TestCase
def test_parse_attr
attr = <<ATTR
( 2.5.4.3 NAME 'cn' OBSOLETE EQUALITY 1.2.3 ORDERING 4.5.678 SUBSTR 9.1.1 SYNTAX 4.3.2{58} SINGLE-VALUE COLLECTIVE NO-USER-MODIFICATION USAGE userApplications )
ATTR
a = LDAP::Server::Schema::AttributeType.new(attr)
assert_equal("2.5.4.3", a.oid)
assert_equal("cn", a.name)
assert_equal(["cn"], a.names)
assert(a.obsolete)
assert_equal("1.2.3", a.equality)
assert_equal("4.5.678", a.ordering)
assert_equal("9.1.1", a.substr)
assert_equal("4.3.2", a.syntax)
assert_equal(58, a.maxlen)
assert(a.singlevalue)
assert(a.collective)
assert(a.nousermod)
assert_equal(:userApplications, a.usage)
assert_equal(attr.chomp, a.to_def)
attr = <<ATTR
( 2.5.4.3 NAME ( 'cn' 'commonName' ) DESC 'RFC2256: common name(s) for which the entity is known by' SUP name )
ATTR
a = LDAP::Server::Schema::AttributeType.new(attr)
assert_equal("2.5.4.3", a.oid)
assert_equal("cn", a.name)
assert_equal(["cn", "commonName"], a.names)
assert_equal("RFC2256: common name(s) for which the entity is known by", a.desc)
assert(! a.obsolete)
assert_equal("name", a.sup)
assert(! a.singlevalue)
assert(! a.collective)
assert(! a.nousermod)
assert_equal(attr.chomp, a.to_def)
end
def test_parse_objectclass
oc = <<OC
( 0.9.2342.19200300.100.4.19 NAME 'simpleSecurityObject' DESC 'RFC1274: simple security object' SUP top AUXILIARY MUST userPassword )
OC
a = LDAP::Server::Schema::ObjectClass.new(oc)
assert_equal("0.9.2342.19200300.100.4.19", a.oid)
assert_equal("simpleSecurityObject", a.name)
assert_equal(["simpleSecurityObject"], a.names)
assert(! a.obsolete)
assert_equal("RFC1274: simple security object", a.desc)
assert_equal(["top"], a.sup)
assert_equal(:auxiliary, a.struct)
assert_equal(["userPassword"], a.must)
assert_equal([], a.may)
assert_equal(oc.chomp, a.to_def)
oc = <<OC
( 2.5.6.6 NAME 'person' DESC 'RFC2256: a person' SUP top STRUCTURAL MUST ( sn $ cn ) MAY ( userPassword $ telephoneNumber $ seeAlso $ description ) )
OC
a = LDAP::Server::Schema::ObjectClass.new(oc)
assert_equal("2.5.6.6", a.oid)
assert_equal("person", a.name)
assert_equal(["person"], a.names)
assert(! a.obsolete)
assert_equal("RFC2256: a person", a.desc)
assert_equal(["top"], a.sup)
assert_equal(:structural, a.struct)
assert_equal(["sn", "cn"], a.must)
assert_equal(["userPassword","telephoneNumber","seeAlso","description"], a.may)
assert_equal(oc.chomp, a.to_def)
end
def test_loadschema
s = LDAP::Server::Schema.new
s.load_system
s.load_file(File.dirname(__FILE__) + "/core.schema")
s.resolve_oids
a = s.find_attrtype("objectclass")
assert_equal("objectClass", a.name)
a = s.find_attrtype("COMMONNAME")
assert_equal(LDAP::Server::Schema::AttributeType, a.class)
assert_equal("caseIgnoreMatch", a.equality.to_s)
assert_equal(LDAP::Server::MatchingRule, a.equality.class)
assert_equal("caseIgnoreSubstringsMatch", a.substr.to_s)
assert_equal(LDAP::Server::MatchingRule, a.substr.class)
assert_equal("1.3.6.1.4.1.1466.115.121.1.15", a.syntax.to_s)
assert_equal(LDAP::Server::Syntax, a.syntax.class)
assert_equal("cn", a.name)
a = s.find_attrtype("COUNTRYname")
assert_equal("c", a.name)
# I modified core.schema so that countryName has the appropriate syntax
assert(a.syntax.match("GB"))
assert(!a.syntax.match("ABC"))
end
def test_backwards_api
s = LDAP::Server::Schema.new
s.load_system
assert_equal(['subschema','OpenLDAProotDSE','referral','alias','extensibleObject','top'].sort,
s.names('objectClasses').sort)
assert_equal(['dITStructureRules','nameForms','ditContentRules','objectClasses','attributeTypes','matchingRules','matchingRuleUse'],
s.attr('subschema', 'may'))
assert_equal([], s.attr('subschema', 'must'))
assert_equal(nil, s.attr('foo', 'must'))
assert_equal(['top'], s.sup('extensibleObject'))
end
def test_validate
# FIXME
end
end

40
test/syntax_test.rb

@ -0,0 +1,40 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/syntax'
class SyntaxTest < Test::Unit::TestCase
def test_integer
s = LDAP::Server::Syntax.find("1.3.6.1.4.1.1466.115.121.1.27")
assert_equal(LDAP::Server::Syntax, s.class)
assert_equal("Integer", s.desc)
assert_equal("( 1.3.6.1.4.1.1466.115.121.1.27 DESC 'Integer' )", s.to_def)
assert(!s.nhr)
assert(s.match("123"))
assert(!s.match("12A"))
assert_equal(123, s.value_from_s("123"))
assert_equal("456", s.value_to_s(456))
assert_equal("789", s.value_to_s("789"))
end
def test_unknown
s = LDAP::Server::Syntax.find("1.4.7.1")
assert_equal(LDAP::Server::Syntax, s.class)
assert_equal("1.4.7.1", s.oid)
assert_equal("1.4.7.1", s.to_s)
assert_equal("( 1.4.7.1 )", s.to_def)
assert_equal("false", s.value_to_s(false)) # generic value_to_s
assert_equal("true", s.value_from_s("true")) # generic value_from_s
assert(s.match("123")) # match anything
end
def test_nil
s = LDAP::Server::Syntax.find(nil)
assert_equal(nil, s)
end
def test_from_def
s = LDAP::Server::Syntax.from_def("( 1.2.3 DESC 'foobar' )")
assert_equal("1.2.3", s.oid)
assert_equal("foobar", s.desc)
end
end

2
test/test_helper.rb

@ -0,0 +1,2 @@
$:.unshift(File.join(File.dirname(__FILE__), '..', 'lib'))
require 'test/unit'

60
test/trie_test.rb

@ -0,0 +1,60 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/trie'
class TestTrie < Test::Unit::TestCase
def test_trie_base
trie = LDAP::Server::Trie.new do |trie|
trie.insert 'ou=Users,dc=mydomain,dc=com,op=bind', 'UsersBindTree'
trie.insert 'ou=Users,dc=mydomain,dc=com,op=search', 'UsersSearchTree'
trie.insert 'ou=Groups,dc=mydomain,dc=com,op=search', 'GroupsSearchTree'
trie.insert 'dc=mydomain,dc=com,op=search', 'RootSearchValue'
trie.insert 'dc=com,op=search', ''
end
assert_equal trie.lookup('ou=Users,dc=mydomain,dc=com,op=bind'), 'UsersBindTree'
assert_equal trie.lookup('ou=Users,dc=mydomain,dc=com,op=search'), 'UsersSearchTree'
assert_equal trie.lookup('ou=Groups,dc=mydomain,dc=com,op=search'), 'GroupsSearchTree'
assert_equal trie.lookup('dc=mydomain,dc=com,op=search'), 'RootSearchValue'
assert_equal trie.lookup('dc=com,op=search'), ''
assert_nil trie.lookup 'dc=mydomain,dc=com,op=bind'
assert_nil trie.lookup nil
assert_raises LDAP::Server::Trie::NodeNotFoundError do
trie.lookup 'ou=DoesNotExist,dc=mydomain,dc=com,op=search'
end
end
def test_trie_wildcard
trie = LDAP::Server::Trie.new do |trie|
trie.insert 'uid=:uid,ou=Users,dc=mydomain,dc=com', 'SpecificUsers'
trie.insert 'ou=Users,dc=mydomain,dc=com', 'Users'
trie.insert 'ou=Users,dc=:domain,dc=:tld', 'Domain'
end
assert_equal trie.lookup('uid=john,ou=Users,dc=mydomain,dc=com'), 'SpecificUsers'
assert_equal trie.lookup('uid=jane,ou=Users,dc=mydomain,dc=com'), 'SpecificUsers'
assert_equal trie.lookup('ou=Users,dc=mydomain,dc=com'), 'Users'
assert_equal trie.lookup('ou=Users,dc=otherdomain,dc=net'), 'Domain'
end
def test_trie_match
trie = LDAP::Server::Trie.new do |trie|
trie.insert 'uid=:uid,ou=Users,dc=mydomain,dc=com', 'SpecificUsers'
trie.insert 'ou=Users,dc=mydomain,dc=com', 'Users'
trie.insert 'dc=mydomain,dc=com', 'Domains'
end
assert_equal trie.match('uid=john,ou=Users,dc=mydomain,dc=com'),
['uid=:uid,ou=Users,dc=mydomain,dc=com', 'SpecificUsers']
assert_equal trie.match('cn=john,ou=Users,dc=mydomain,dc=com'),
['ou=Users,dc=mydomain,dc=com', 'Users']
assert_equal trie.match('ou=Users,dc=mydomain,dc=com'),
['ou=Users,dc=mydomain,dc=com', 'Users']
assert_equal trie.match('dc=mydomain,dc=com'),
['dc=mydomain,dc=com', 'Domains']
assert_equal trie.match('dc=otherdomain,dc=com'),
[nil, nil]
end
end

51
test/util_test.rb

@ -0,0 +1,51 @@
require File.dirname(__FILE__) + '/test_helper'
require 'ldap/server/util'
class TestLdapUtil < Test::Unit::TestCase
def test_split_dn
# examples from RFC 2253
assert_equal(
[{"cn"=>"Steve Kille"},{"o"=>"Isode Limited"},{"c"=>"GB"}],
LDAP::Server::Operation.split_dn("CN=Steve Kille , O=Isode Limited;C=GB")
)
assert_equal(
[{"ou"=>"Sales","cn"=>"J. Smith"},{"o"=>"Widget Inc."},{"c"=>"US"}],
LDAP::Server::Operation.split_dn("OU=Sales+CN=J. Smith,O=Widget Inc.,C=US")
)
assert_equal(
[{"cn"=>"L. Eagle"},{"o"=>"Sue, Grabbit and Runn"},{"c"=>"GB"}],
LDAP::Server::Operation.split_dn("CN=L. Eagle,O=Sue\\, Grabbit and Runn,C=GB")
)
assert_equal(
[{"cn"=>"Before\rAfter"},{"o"=>"Test"},{"c"=>"GB"}],
LDAP::Server::Operation.split_dn("CN=Before\\0DAfter,O=Test,C=GB")
)
res = LDAP::Server::Operation.split_dn("SN=Lu\\C4\\8Di\\C4\\87")
assert_equal([{ "sn" => "Lu\xc4\x8di\xc4\x87".force_encoding('ascii-8bit') }], res)
# Just for fun, let's try parsing it as UTF8
chars = res[0]["sn"].force_encoding('utf-8').scan(/./u)
assert_equal(["L", "u", "\u010d", "i", "\u0107"], chars)
end
def test_join_dn
# examples from RFC 2253
assert_equal(
"cn=Steve Kille,o=Isode Limited,c=GB",
LDAP::Server::Operation.join_dn([{"cn"=>"Steve Kille"},{"o"=>"Isode Limited"},{"c"=>"GB"}])
)
# These are equivalent
d1 = "ou=Sales+cn=J. Smith,o=Widget Inc.,c=US"
d2 = "cn=J. Smith+ou=Sales,o=Widget Inc.,c=US"
assert_equal(d1,
LDAP::Server::Operation.join_dn([[["ou","Sales"],["cn","J. Smith"]],[["o","Widget Inc."]],["c","US"]])
)
r = LDAP::Server::Operation.join_dn([{"ou"=>"Sales","cn"=>"J. Smith"},{"o"=>"Widget Inc."},{"c"=>"US"}])
assert(r == d1 || r == d2, "got #{r.inspect}, expected #{d1.inspect} or #{d2.inspect}")
assert_equal(
"cn=L. Eagle,o=Sue\\, Grabbit and Runn,c=GB",
LDAP::Server::Operation.join_dn([{"cn"=>"L. Eagle"},{"o"=>"Sue, Grabbit and Runn"},{"c"=>"GB"}])
)
end
end
Loading…
Cancel
Save