1#!/usr/bin/env python2
2# -*- coding: utf-8 -*-
3#   BAREOS - Backup Archiving REcovery Open Sourced
4#
5#   Copyright (C) 2020-2020 Bareos GmbH & Co. KG
6#
7#   This program is Free Software; you can redistribute it and/or
8#   modify it under the terms of version three of the GNU Affero General Public
9#   License as published by the Free Software Foundation and included
10#   in the file LICENSE.
11#
12#   This program is distributed in the hope that it will be useful, but
13#   WITHOUT ANY WARRANTY; without even the implied warranty of
14#   MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
15#   Affero General Public License for more details.
16#
17#   You should have received a copy of the GNU Affero General Public License
18#   along with this program; if not, write to the Free Software
19#   Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA
20#   02110-1301, USA.
21
22from __future__ import division, absolute_import, print_function, unicode_literals
23import io
24import ldap
25import ldap.modlist
26import ldif
27import sys
28import hashlib
29from argparse import ArgumentParser
30from ldif import LDIFWriter
31
32
33def _safe_encode(data):
34    if isinstance(data, unicode):
35        return data.encode("utf-8")
36    return data
37
38
39class BytesLDIFRecordList(ldif.LDIFRecordList):
40    """Simple encoding wrapper for LDIFRecordList that converts keys to UTF-8"""
41
42    def _next_key_and_value(self):
43        # we do not descend from object, so we cannot use super()
44        k, v = ldif.LDIFRecordList._next_key_and_value(self)
45        return k.encode("utf-8"), v
46
47
48def ldap_connect(address, binddn, password):
49    try:
50        conn = ldap.initialize("ldap://%s" % address, bytes_mode=True)
51    except TypeError:
52        conn = ldap.initialize("ldap://%s" % address)
53
54    conn.set_option(ldap.OPT_REFERRALS, 0)
55    conn.simple_bind_s(binddn, password)
56
57    return conn
58
59
60def ldap_create_or_fail(conn, dn, modlist):
61    """
62    create an object dn with the attributes from modlist dict
63    """
64    try:
65        conn.add_s(dn, ldap.modlist.addModlist(modlist))
66    except ldap.ALREADY_EXISTS:
67        print("Object '%s' already exists." % dn, file=sys.stderr)
68        sys.exit(1)
69
70
71def action_clean(conn, basedn):
72    """
73    Clean up all objects in our subtrees (ldap-variant of "rm -rf").
74
75    find the dns ob all objects below our bases and remove them ordered from
76    longest to shortest dn, so we remove parent objects later
77    """
78
79    for subtree_dn in [b"ou=backup,%s" % basedn, b"ou=restore,%s" % basedn]:
80        try:
81            for dn in sorted(
82                map(
83                    lambda x: x[0],
84                    conn.search_s(subtree_dn, ldap.SCOPE_SUBTREE, attrsonly=1),
85                ),
86                key=len,
87                reverse=True,
88            ):
89                conn.delete_s(dn)
90        except ldap.NO_SUCH_OBJECT:
91            # if the top object doesn't exist, there's nothing to remove
92            pass
93
94
95def action_populate(conn, basedn):
96    """Populate our backup data"""
97    ldap_create_or_fail(
98        conn,
99        b"ou=backup,%s" % basedn,
100        {b"objectClass": [b"organizationalUnit"], b"ou": [b"restore"]},
101    )
102
103    ldap_create_or_fail(
104        conn,
105        b"cn=No JPEG,ou=backup,%s" % basedn,
106        {
107            b"objectClass": [b"inetOrgPerson", b"posixAccount", b"shadowAccount"],
108            b"uid": [b"njpeg"],
109            b"sn": [b"JPEG"],
110            b"givenName": [b"No"],
111            b"cn": [b"No JPEG"],
112            b"displayName": [b"No JPEG"],
113            b"uidNumber": [b"1000"],
114            b"gidNumber": [b"1000"],
115            b"loginShell": [b"/bin/bash"],
116            b"homeDirectory": [b"/home/njpeg"],
117        },
118    )
119
120    ldap_create_or_fail(
121        conn,
122        b"cn=Small JPEG,ou=backup,%s" % basedn,
123        {
124            b"objectClass": [b"inetOrgPerson", b"posixAccount", b"shadowAccount"],
125            b"uid": [b"sjpeg"],
126            b"sn": [b"JPEG"],
127            b"givenName": [b"Small"],
128            b"cn": [b"Small JPEG"],
129            b"displayName": [b"Small JPEG"],
130            b"uidNumber": [b"1001"],
131            b"gidNumber": [b"1000"],
132            b"loginShell": [b"/bin/bash"],
133            b"homeDirectory": [b"/home/sjpeg"],
134            b"jpegPhoto": open("image-small.jpg", "rb").read(),
135        },
136    )
137
138    ldap_create_or_fail(
139        conn,
140        b"cn=Medium JPEG,ou=backup,%s" % basedn,
141        {
142            b"objectClass": [b"inetOrgPerson", b"posixAccount", b"shadowAccount"],
143            b"uid": [b"mjpeg"],
144            b"sn": [b"JPEG"],
145            b"givenName": [b"Medium"],
146            b"cn": [b"Medium JPEG"],
147            b"displayName": [b"Medium JPEG"],
148            b"uidNumber": [b"1002"],
149            b"gidNumber": [b"1000"],
150            b"loginShell": [b"/bin/bash"],
151            b"homeDirectory": [b"/home/mjpeg"],
152            b"jpegPhoto": open("image-medium.jpg", "rb").read(),
153        },
154    )
155
156    ldap_create_or_fail(
157        conn,
158        b"cn=Large JPEG,ou=backup,%s" % basedn,
159        {
160            b"objectClass": [b"inetOrgPerson", b"posixAccount", b"shadowAccount"],
161            b"uid": [b"ljpeg"],
162            b"sn": [b"JPEG"],
163            b"givenName": [b"Large"],
164            b"cn": [b"Large JPEG"],
165            b"displayName": [b"Large JPEG"],
166            b"uidNumber": [b"1003"],
167            b"gidNumber": [b"1000"],
168            b"loginShell": [b"/bin/bash"],
169            b"homeDirectory": [b"/home/ljpeg"],
170            b"jpegPhoto": open("image-large.jpg", "rb").read(),
171        },
172    )
173
174    ldap_create_or_fail(
175        conn,
176        b"o=Bareos GmbH & Co. KG,ou=backup,%s" % basedn,
177        {b"objectClass": [b"top", b"organization"], b"o": [b"Bareos GmbH & Co. KG"]},
178    )
179
180    ldap_create_or_fail(
181        conn,
182        b"ou=automount,ou=backup,%s" % basedn,
183        {b"objectClass": [b"top", b"organizationalUnit"], b"ou": [b"automount"]},
184    )
185
186    # # Objects with / in the DN are currently not supported
187    # ldap_create_or_fail(
188    #     conn,
189    #     "cn=/home,ou=automount,ou=backup,%s" % basedn,
190    #     {
191    #         "objectClass": [b"top", b"person"],
192    #         "cn": [b"/home"],
193    #         "sn": [b"Automount objects don't have a surname"],
194    #     },
195    # )
196
197    ldap_create_or_fail(
198        conn,
199        b"ou=weird-names,ou=backup,%s" % basedn,
200        {b"objectClass": [b"top", b"organizationalUnit"], b"ou": [b"weird-names"]},
201    )
202
203    for ou in [
204        b" leading-space",
205        b"#leading-hash",
206        b"space in middle",
207        b"trailing-space ",
208        b"with\nnewline",
209        b"with,comma",
210        b'with"quotes"',
211        b"with\\backslash",
212        b"with+plus",
213        b"with#hash",
214        b"with;semicolon",
215        b"with<less-than",
216        b"with=equals",
217        b"with>greater-than",
218    ]:
219        ldap_create_or_fail(
220            conn,
221            b"ou=%s,ou=weird-names,ou=backup,%s"
222            % (ldap.dn.escape_dn_chars(ou), basedn),
223            {b"objectClass": [b"top", b"organizationalUnit"], b"ou": [ou]},
224        )
225
226    # creating the DN using the normal method wouldn't work, so we create a
227    # temporary LDIF and parse that.
228    ldif_data = io.BytesIO()
229    ldif_data.write(b"dn: ou=böses encoding,ou=weird-names,ou=backup,")
230    ldif_data.write(b"%s\n" % basedn.encode("ascii"))
231    ldif_data.write(b"objectClass: top\n")
232    ldif_data.write(b"objectClass: organizationalUnit\n")
233    ldif_data.write(b"ou: böses encoding\n")
234    ldif_data.seek(0)
235
236    ldif_parser = BytesLDIFRecordList(ldif_data, max_entries=1)
237    ldif_parser.parse()
238    dn, entry = ldif_parser.all_records[0]
239    ldif_data.close()
240
241    ldap_create_or_fail(conn, _safe_encode(dn), entry)
242
243
244def abbrev_value(v):
245    """Abbreviate long values for readable LDIF output"""
246    length = len(v)
247    if length > 80:
248        digest = hashlib.sha1(v).hexdigest()
249        return "BLOB len:%d sha1:%s" % (length, digest)
250    return v
251
252
253def action_dump(conn, basedn, shorten=True, rewrite_dn=True):
254    writer = LDIFWriter(sys.stdout)
255    try:
256        for dn, attrs in conn.search_s(basedn, ldap.SCOPE_SUBTREE):
257            if rewrite_dn:
258                dn = (
259                    dn.decode("utf-8")
260                    .replace(basedn, "dc=unified,dc=base,dc=dn")
261                    .encode("utf-8")
262                )
263            if shorten:
264                attrs = {
265                    k: [abbrev_value(v) for v in vals] for k, vals in attrs.iteritems()
266                }
267            try:
268                writer.unparse(dn, attrs)
269            except UnicodeDecodeError:
270                writer.unparse(dn.decode("utf-8"), attrs)
271    except ldap.NO_SUCH_OBJECT:
272        print("No object '%s' in directory." % basedn, file=sys.stderr)
273        sys.exit(1)
274
275
276if __name__ == "__main__":
277    parser = ArgumentParser(description="Tool to create, remove LDAP test data")
278    parser.add_argument(
279        "--clean", action="store_true", help="remove data from LDAP server"
280    )
281    parser.add_argument(
282        "--populate", action="store_true", help="populate LDAP server with data"
283    )
284    parser.add_argument(
285        "--dump-backup",
286        action="store_true",
287        help="print representation of backup subtree",
288    )
289    parser.add_argument(
290        "--dump-restore",
291        action="store_true",
292        help="print representation of restore subtree",
293    )
294    parser.add_argument(
295        "--full-value",
296        action="store_true",
297        help="disable shortening of large values during dump",
298    )
299    parser.add_argument(
300        "--real-dn", action="store_true", help="disable rewriting of DN during dump"
301    )
302    parser.add_argument(
303        "--address", "--host", default="localhost", help="LDAP server address"
304    )
305    parser.add_argument(
306        "--basedn", "-b", default=b"dc=example,dc=org", help="LDAP base dn"
307    )
308    parser.add_argument(
309        "--binddn", "-D", default=b"cn=admin,dc=example,dc=org", help="LDAP bind dn"
310    )
311    parser.add_argument("--password", "-w", default=b"admin", help="LDAP password")
312
313    args = parser.parse_args()
314
315    if (
316        not args.clean
317        and not args.populate
318        and not args.dump_backup
319        and not args.dump_restore
320    ):
321        print("please select at least one action", file=sys.stderr)
322        sys.exit(1)
323
324    conn = ldap_connect(args.address, args.binddn, args.password)
325    if args.clean:
326        action_clean(conn, args.basedn)
327    if args.populate:
328        action_populate(conn, args.basedn)
329    if args.dump_backup:
330        action_dump(
331            conn,
332            b"ou=backup,%s" % args.basedn,
333            shorten=not args.full_value,
334            rewrite_dn=not args.real_dn,
335        )
336    if args.dump_restore:
337        action_dump(
338            conn,
339            b"ou=restore,%s" % args.basedn,
340            shorten=not args.full_value,
341            rewrite_dn=not args.real_dn,
342        )
343