001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2016 ForgeRock AS.
015 *
016 */
017package org.forgerock.opendj.rest2ldap;
018
019import static java.util.Arrays.asList;
020import static java.util.Collections.emptyList;
021import static org.forgerock.http.routing.RouteMatchers.newResourceApiVersionBehaviourManager;
022import static org.forgerock.http.routing.RoutingMode.STARTS_WITH;
023import static org.forgerock.http.routing.Version.version;
024import static org.forgerock.http.util.Json.readJsonLenient;
025import static org.forgerock.json.JsonValueFunctions.enumConstant;
026import static org.forgerock.json.JsonValueFunctions.pointer;
027import static org.forgerock.json.JsonValueFunctions.setOf;
028import static org.forgerock.json.resource.RouteMatchers.requestUriMatcher;
029import static org.forgerock.json.resource.RouteMatchers.resourceApiVersionContextFilter;
030import static org.forgerock.opendj.ldap.Connections.LOAD_BALANCER_MONITORING_INTERVAL;
031import static org.forgerock.opendj.ldap.Connections.newCachedConnectionPool;
032import static org.forgerock.opendj.ldap.Connections.newFailoverLoadBalancer;
033import static org.forgerock.opendj.ldap.Connections.newRoundRobinLoadBalancer;
034import static org.forgerock.opendj.ldap.KeyManagers.useJvmDefaultKeyStore;
035import static org.forgerock.opendj.ldap.KeyManagers.useKeyStoreFile;
036import static org.forgerock.opendj.ldap.KeyManagers.usePKCS11Token;
037import static org.forgerock.opendj.ldap.KeyManagers.useSingleCertificate;
038import static org.forgerock.opendj.ldap.LDAPConnectionFactory.*;
039import static org.forgerock.opendj.ldap.TrustManagers.checkUsingTrustStore;
040import static org.forgerock.opendj.ldap.TrustManagers.trustAll;
041import static org.forgerock.opendj.rest2ldap.ReadOnUpdatePolicy.CONTROLS;
042import static org.forgerock.opendj.rest2ldap.Rest2Ldap.*;
043import static org.forgerock.opendj.rest2ldap.Rest2ldapMessages.*;
044import static org.forgerock.opendj.rest2ldap.Utils.newJsonValueException;
045import static org.forgerock.util.Utils.joinAsString;
046import static org.forgerock.util.time.Duration.duration;
047
048import java.io.BufferedReader;
049import java.io.File;
050import java.io.FileFilter;
051import java.io.FileInputStream;
052import java.io.FileReader;
053import java.io.IOException;
054import java.io.InputStream;
055import java.security.GeneralSecurityException;
056import java.util.ArrayList;
057import java.util.Collections;
058import java.util.LinkedHashMap;
059import java.util.LinkedList;
060import java.util.List;
061import java.util.Map;
062import java.util.concurrent.TimeUnit;
063
064import javax.net.ssl.TrustManager;
065import javax.net.ssl.X509KeyManager;
066
067import org.forgerock.http.routing.ResourceApiVersionBehaviourManager;
068import org.forgerock.i18n.LocalizedIllegalArgumentException;
069import org.forgerock.i18n.slf4j.LocalizedLogger;
070import org.forgerock.json.JsonValue;
071import org.forgerock.json.resource.BadRequestException;
072import org.forgerock.json.resource.FilterChain;
073import org.forgerock.json.resource.Request;
074import org.forgerock.json.resource.RequestHandler;
075import org.forgerock.json.resource.ResourceException;
076import org.forgerock.json.resource.Router;
077import org.forgerock.opendj.ldap.ConnectionFactory;
078import org.forgerock.opendj.ldap.LDAPConnectionFactory;
079import org.forgerock.opendj.ldap.SSLContextBuilder;
080import org.forgerock.opendj.ldap.requests.BindRequest;
081import org.forgerock.opendj.ldap.requests.Requests;
082import org.forgerock.services.context.Context;
083import org.forgerock.util.Options;
084import org.forgerock.util.promise.Promise;
085import org.forgerock.util.time.Duration;
086
087/** Provides core factory methods and builders for constructing Rest2Ldap endpoints from JSON configuration. */
088public final class Rest2LdapJsonConfigurator {
089    private static final LocalizedLogger logger = LocalizedLogger.getLoggerForThisClass();
090
091    /**
092     * Parses Rest2Ldap configuration options. The JSON configuration must have the following format:
093     * <p>
094     * <pre>
095     * {
096     *      "readOnUpdatePolicy": "controls",
097     *      "useSubtreeDelete": true,
098     *      "usePermissiveModify": true,
099     *      "useMvcc": true
100     *      "mvccAttribute": "etag"
101     * }
102     * </pre>
103     * <p>
104     * See the sample configuration file for a detailed description of its content.
105     *
106     * @param config
107     *         The JSON configuration.
108     * @return The parsed Rest2Ldap configuration options.
109     * @throws IllegalArgumentException
110     *         If the configuration is invalid.
111     */
112    public static Options configureOptions(final JsonValue config) {
113        final Options options = Options.defaultOptions();
114
115        options.set(READ_ON_UPDATE_POLICY,
116                    config.get("readOnUpdatePolicy").defaultTo(CONTROLS).as(enumConstant(ReadOnUpdatePolicy.class)));
117
118        // Default to false, even though it is supported by OpenDJ, because it requires additional permissions.
119        options.set(USE_SUBTREE_DELETE, config.get("useSubtreeDelete").defaultTo(false).asBoolean());
120
121        // Default to true because it is supported by OpenDJ and does not require additional permissions.
122        options.set(USE_PERMISSIVE_MODIFY, config.get("usePermissiveModify").defaultTo(false).asBoolean());
123
124        options.set(USE_MVCC, config.get("useMvcc").defaultTo(true).asBoolean());
125        options.set(MVCC_ATTRIBUTE, config.get("mvccAttribute").defaultTo("etag").asString());
126
127        return options;
128    }
129
130    /**
131     * Parses a list of Rest2Ldap resource definitions. The JSON configuration must have the following format:
132     * <p>
133     * <pre>
134     * "top": {
135     *     "isAbstract": true,
136     *     "properties": {
137     *         "_rev": {
138     *             "type": "simple"
139     *             "ldapAttribute": "etag",
140     *             "writability": "readOnly"
141     *         },
142     *         ...
143     *     },
144     *     ...
145     * },
146     * ...
147     * </pre>
148     * <p>
149     * See the sample configuration file for a detailed description of its content.
150     *
151     * @param config
152     *         The JSON configuration.
153     * @return The parsed list of Rest2Ldap resource definitions.
154     * @throws IllegalArgumentException
155     *         If the configuration is invalid.
156     */
157    public static List<Resource> configureResources(final JsonValue config) {
158        final JsonValue resourcesConfig = config.required().expect(Map.class);
159        final List<Resource> resources = new LinkedList<>();
160        for (final String resourceId : resourcesConfig.keys()) {
161            resources.add(configureResource(resourceId, resourcesConfig.get(resourceId)));
162        }
163        return resources;
164    }
165
166    /**
167     * Creates a new CREST {@link Router} using the provided endpoints configuration directory and Rest2Ldap options.
168     * The Rest2Ldap configuration typically has the following structure on disk:
169     * <ul>
170     * <li> config.json - contains the configuration for the LDAP connection factories and authorization
171     * <li> rest2ldap/rest2ldap.json - defines Rest2Ldap configuration options
172     * <li> rest2ldap/endpoints/{api} - a directory containing the endpoint's resource definitions for endpoint {api}
173     * <li> rest2ldap/endpoints/{api}/{resource-id}.json - the resource definitions for a specific version of API {api}.
174     * The name of the file, {resource-id}, determines which resource type definition in the mapping file will be
175     * used as the root resource.
176     * </ul>
177     *
178     * @param endpointsDirectory The directory representing the Rest2Ldap "endpoints" directory.
179     * @param options The Rest2Ldap configuration options.
180     * @return A new CREST {@link Router} configured using the provided options and endpoints.
181     * @throws IOException If the endpoints configuration cannot be read.
182     * @throws IllegalArgumentException
183     *         If the configuration is invalid.
184     */
185    public static Router configureEndpoints(final File endpointsDirectory, final Options options) throws IOException {
186        final Router pathRouter = new Router();
187
188        final File[] endpoints = endpointsDirectory.listFiles(new FileFilter() {
189            @Override
190            public boolean accept(final File pathname) {
191                return pathname.isDirectory() && pathname.canRead();
192            }
193        });
194
195        if (endpoints == null) {
196            throw new LocalizedIllegalArgumentException(ERR_INVALID_ENDPOINTS_DIRECTORY.get(endpointsDirectory));
197        }
198
199        for (final File endpoint : endpoints) {
200            final RequestHandler endpointHandler = configureEndpoint(endpoint, options);
201            pathRouter.addRoute(requestUriMatcher(STARTS_WITH, endpoint.getName()), endpointHandler);
202        }
203        return pathRouter;
204    }
205
206    /**
207     * Creates a new CREST {@link RequestHandler} representing a single endpoint whose configuration is defined in the
208     * provided {@code endpointDirectory} parameter. The directory should contain a separate file for each supported
209     * version of the REST endpoint. The name of the file, excluding the suffix, identifies the resource definition
210     * which acts as the entry point into the endpoint.
211     *
212     * @param endpointDirectory The directory containing the endpoint's resource definitions, e.g.
213     *                          rest2ldap/routes/api would contain definitions for the "api" endpoint.
214     * @param options The Rest2Ldap configuration options.
215     * @return A new CREST {@link RequestHandler} configured using the provided options and endpoint mappings.
216     * @throws IOException If the endpoint configuration cannot be read.
217     * @throws IllegalArgumentException If the configuration is invalid.
218     */
219    public static RequestHandler configureEndpoint(File endpointDirectory, Options options) throws IOException {
220        final Router versionRouter = new Router();
221        final File[] endpointVersions = endpointDirectory.listFiles(new FileFilter() {
222            @Override
223            public boolean accept(final File pathname) {
224                return pathname.isFile() && pathname.canRead() && pathname.getName().endsWith(".json");
225            }
226        });
227
228        if (endpointVersions == null) {
229            throw new LocalizedIllegalArgumentException(ERR_INVALID_ENDPOINT_DIRECTORY.get(endpointDirectory));
230        }
231
232        final List<String> supportedVersions = new ArrayList<>();
233        boolean hasWildCardVersion = false;
234        for (final File endpointVersion : endpointVersions) {
235            final JsonValue mappingConfig = readJson(endpointVersion);
236            final String version = mappingConfig.get("version").defaultTo("*").asString();
237            final List<Resource> resourceTypes = configureResources(mappingConfig.get("resourceTypes"));
238            final Rest2Ldap rest2Ldap = rest2Ldap(options, resourceTypes);
239
240            final String endpointVersionFileName = endpointVersion.getName();
241            final int endIndex = endpointVersionFileName.lastIndexOf('.');
242            final String rootResourceType = endpointVersionFileName.substring(0, endIndex);
243            final RequestHandler handler = rest2Ldap.newRequestHandlerFor(rootResourceType);
244
245            if (version.equals("*")) {
246                versionRouter.setDefaultRoute(handler);
247                hasWildCardVersion = true;
248            } else {
249                versionRouter.addRoute(version(version), handler);
250                supportedVersions.add(version);
251            }
252            logger.debug(INFO_REST2LDAP_CREATING_ENDPOINT.get(endpointDirectory.getName(), version));
253        }
254        if (!hasWildCardVersion) {
255            versionRouter.setDefaultRoute(new AbstractRequestHandler() {
256                @Override
257                protected <V> Promise<V, ResourceException> handleRequest(Context context, Request request) {
258                    final String message = ERR_BAD_API_RESOURCE_VERSION.get(request.getResourceVersion(),
259                                                                            joinAsString(", ", supportedVersions))
260                                                                       .toString();
261                    return new BadRequestException(message).asPromise();
262                }
263            });
264        }
265
266        // FIXME: Disable the warning header for now due to CREST-389 / CREST-390.
267        final ResourceApiVersionBehaviourManager behaviourManager = newResourceApiVersionBehaviourManager();
268        behaviourManager.setWarningEnabled(false);
269        return new FilterChain(versionRouter, resourceApiVersionContextFilter(behaviourManager));
270    }
271
272    static JsonValue readJson(final File resource) throws IOException {
273        try (InputStream in = new FileInputStream(resource)) {
274            return new JsonValue(readJsonLenient(in));
275        }
276    }
277
278    private static Resource configureResource(final String resourceId, final JsonValue config) {
279        final Resource resource = resource(resourceId)
280                .isAbstract(config.get("isAbstract").defaultTo(false).asBoolean())
281                .superType(config.get("superType").asString())
282                .objectClasses(config.get("objectClasses")
283                                     .defaultTo(emptyList()).asList(String.class).toArray(new String[0]))
284                .supportedActions(config.get("supportedActions")
285                                        .defaultTo(emptyList())
286                                        .as(setOf(enumConstant(Action.class))).toArray(new Action[0]))
287                .resourceTypeProperty(config.get("resourceTypeProperty").as(pointer()))
288                .includeAllUserAttributesByDefault(config.get("includeAllUserAttributesByDefault")
289                                                         .defaultTo(false).asBoolean())
290                .excludedDefaultUserAttributes(config.get("excludedDefaultUserAttributes")
291                                                     .defaultTo(Collections.emptyList()).asList(String.class));
292
293        final JsonValue properties = config.get("properties").expect(Map.class);
294        for (final String property : properties.keys()) {
295            resource.property(property, configurePropertyMapper(properties.get(property), property));
296        }
297
298        final JsonValue subResources = config.get("subResources").expect(Map.class);
299        for (final String urlTemplate : subResources.keys()) {
300            resource.subResource(configureSubResource(urlTemplate, subResources.get(urlTemplate)));
301        }
302
303        return resource;
304    }
305
306    private enum NamingStrategyType { CLIENTDNNAMING, CLIENTNAMING, SERVERNAMING }
307    private enum SubResourceType { COLLECTION, SINGLETON }
308
309    private static SubResource configureSubResource(final String urlTemplate, final JsonValue config) {
310        final String dnTemplate = config.get("dnTemplate").defaultTo("").asString();
311        final Boolean isReadOnly = config.get("isReadOnly").defaultTo(false).asBoolean();
312        final String resourceId = config.get("resource").required().asString();
313
314        if (config.get("type").required().as(enumConstant(SubResourceType.class)) == SubResourceType.COLLECTION) {
315            final String[] glueObjectClasses = config.get("glueObjectClasses")
316                                                     .defaultTo(emptyList())
317                                                     .asList(String.class)
318                                                     .toArray(new String[0]);
319
320            final SubResourceCollection collection = collectionOf(resourceId).urlTemplate(urlTemplate)
321                                                                             .dnTemplate(dnTemplate)
322                                                                             .isReadOnly(isReadOnly)
323                                                                             .glueObjectClasses(glueObjectClasses);
324
325            final JsonValue namingStrategy = config.get("namingStrategy").required();
326            switch (namingStrategy.get("type").required().as(enumConstant(NamingStrategyType.class))) {
327            case CLIENTDNNAMING:
328                collection.useClientDnNaming(namingStrategy.get("dnAttribute").required().asString());
329                break;
330            case CLIENTNAMING:
331                collection.useClientNaming(namingStrategy.get("dnAttribute").required().asString(),
332                                           namingStrategy.get("idAttribute").required().asString());
333                break;
334            case SERVERNAMING:
335                collection.useServerNaming(namingStrategy.get("dnAttribute").required().asString(),
336                                           namingStrategy.get("idAttribute").required().asString());
337                break;
338            }
339
340            return collection;
341        } else {
342            return singletonOf(resourceId).urlTemplate(urlTemplate).dnTemplate(dnTemplate).isReadOnly(isReadOnly);
343        }
344    }
345
346    private static PropertyMapper configurePropertyMapper(final JsonValue mapper, final String defaultLdapAttribute) {
347        switch (mapper.get("type").required().asString()) {
348        case "resourceType":
349            return resourceType();
350        case "constant":
351            return constant(mapper.get("value").getObject());
352        case "simple":
353            return simple(mapper.get("ldapAttribute").defaultTo(defaultLdapAttribute).required().asString())
354                    .defaultJsonValue(mapper.get("defaultJsonValue").getObject())
355                    .isBinary(mapper.get("isBinary").defaultTo(false).asBoolean())
356                    .isRequired(mapper.get("isRequired").defaultTo(false).asBoolean())
357                    .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
358                    .writability(parseWritability(mapper));
359        case "json":
360            return json(mapper.get("ldapAttribute").defaultTo(defaultLdapAttribute).required().asString())
361                    .defaultJsonValue(mapper.get("defaultJsonValue").getObject())
362                    .isRequired(mapper.get("isRequired").defaultTo(false).asBoolean())
363                    .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
364                    .jsonSchema(mapper.isDefined("schema") ? mapper.get("schema") : null)
365                    .writability(parseWritability(mapper));
366        case "reference":
367            final String ldapAttribute = mapper.get("ldapAttribute")
368                                               .defaultTo(defaultLdapAttribute).required().asString();
369            final String baseDN = mapper.get("baseDn").required().asString();
370            final String primaryKey = mapper.get("primaryKey").required().asString();
371            final PropertyMapper m = configurePropertyMapper(mapper.get("mapper").required(), primaryKey);
372            return reference(ldapAttribute, baseDN, primaryKey, m)
373                    .isRequired(mapper.get("isRequired").defaultTo(false).asBoolean())
374                    .isMultiValued(mapper.get("isMultiValued").defaultTo(false).asBoolean())
375                    .searchFilter(mapper.get("searchFilter").defaultTo("(objectClass=*)").asString())
376                    .writability(parseWritability(mapper));
377        case "object":
378            final JsonValue properties = mapper.get("properties");
379            final ObjectPropertyMapper object = object();
380            for (final String attribute : properties.keys()) {
381                object.property(attribute, configurePropertyMapper(properties.get(attribute), attribute));
382            }
383            return object;
384        default:
385            throw newJsonValueException(mapper, ERR_CONFIG_NO_MAPPING_IN_CONFIGURATION.get(
386                    "constant, simple, reference, object"));
387        }
388    }
389
390    private static WritabilityPolicy parseWritability(final JsonValue mapper) {
391        return mapper.get("writability").defaultTo("readWrite").as(enumConstant(WritabilityPolicy.class));
392    }
393
394    /** Indicates whether LDAP client connections should use SSL or StartTLS. */
395    private enum ConnectionSecurity { NONE, SSL, STARTTLS }
396
397    /** Specifies the mechanism which will be used for trusting certificates presented by the LDAP server. */
398    private enum TrustManagerType { TRUSTALL, JVM, FILE }
399
400    /** Specifies the type of key-store to use when performing SSL client authentication. */
401    private enum KeyManagerType { JVM, FILE, PKCS11 }
402
403    /**
404     * Configures a {@link X509KeyManager} using the provided JSON configuration.
405     *
406     * @param configuration
407     *         The JSON object containing the key manager configuration.
408     * @return The configured key manager.
409     */
410    public static X509KeyManager configureKeyManager(final JsonValue configuration) {
411        try {
412            return configureKeyManager(configuration, KeyManagerType.JVM);
413        } catch (GeneralSecurityException | IOException e) {
414            throw new IllegalArgumentException(ERR_CONFIG_INVALID_KEY_MANAGER.get(
415                    configuration.getPointer(), e.getLocalizedMessage()).toString(), e);
416        }
417    }
418
419    private static X509KeyManager configureKeyManager(JsonValue config, KeyManagerType defaultIfMissing)
420            throws GeneralSecurityException, IOException {
421        final KeyManagerType keyManagerType = config.get("keyManager")
422                                                    .defaultTo(defaultIfMissing)
423                                                    .as(enumConstant(KeyManagerType.class));
424        switch (keyManagerType) {
425        case JVM:
426            return useJvmDefaultKeyStore();
427        case FILE:
428            final String fileName = config.get("fileBasedKeyManagerFile").required().asString();
429            final String passwordFile = config.get("fileBasedKeyManagerPasswordFile").asString();
430            final String password = passwordFile != null
431                    ? readPasswordFromFile(passwordFile) : config.get("fileBasedKeyManagerPassword").asString();
432            final String type = config.get("fileBasedKeyManagerType").asString();
433            final String provider = config.get("fileBasedKeyManagerProvider").asString();
434            return useKeyStoreFile(fileName, password != null ? password.toCharArray() : null, type, provider);
435        case PKCS11:
436            final String pkcs11PasswordFile = config.get("pkcs11KeyManagerPasswordFile").asString();
437            return usePKCS11Token(pkcs11PasswordFile != null
438                                          ? readPasswordFromFile(pkcs11PasswordFile).toCharArray() : null);
439        default:
440            throw new IllegalArgumentException("Unsupported key-manager type: " + keyManagerType);
441        }
442    }
443
444    private static String readPasswordFromFile(String fileName) throws IOException {
445        try (final BufferedReader reader = new BufferedReader(new FileReader(new File(fileName)))) {
446            return reader.readLine();
447        }
448    }
449
450    /**
451     * Configures a {@link TrustManager} using the provided JSON configuration.
452     *
453     * @param configuration
454     *         The JSON object containing the trust manager configuration.
455     * @return The configured trust manager.
456     */
457    public static TrustManager configureTrustManager(final JsonValue configuration) {
458        try {
459            return configureTrustManager(configuration, TrustManagerType.JVM);
460        } catch (GeneralSecurityException | IOException e) {
461            throw new IllegalArgumentException(ERR_CONFIG_INVALID_TRUST_MANAGER.get(
462                    configuration.getPointer(), e.getLocalizedMessage()).toString(), e);
463        }
464    }
465
466    private static TrustManager configureTrustManager(JsonValue config, TrustManagerType defaultIfMissing)
467            throws GeneralSecurityException, IOException {
468        final TrustManagerType trustManagerType = config.get("trustManager")
469                                                        .defaultTo(defaultIfMissing)
470                                                        .as(enumConstant(TrustManagerType.class));
471        switch (trustManagerType) {
472        case TRUSTALL:
473            return trustAll();
474        case JVM:
475            return null;
476        case FILE:
477            final String fileName = config.get("fileBasedTrustManagerFile").required().asString();
478            final String passwordFile = config.get("fileBasedTrustManagerPasswordFile").asString();
479            final String password = passwordFile != null
480                    ? readPasswordFromFile(passwordFile) : config.get("fileBasedTrustManagerPassword").asString();
481            final String type = config.get("fileBasedTrustManagerType").asString();
482            return checkUsingTrustStore(fileName, password != null ? password.toCharArray() : null, type);
483        default:
484            throw new IllegalArgumentException("Unsupported trust-manager type: " + trustManagerType);
485        }
486    }
487
488    /**
489     * Creates a new connection factory using the named configuration in the provided JSON list of factory
490     * configurations. See the sample configuration file for a detailed description of its content.
491     *
492     * @param configuration
493     *         The JSON configuration.
494     * @param name
495     *         The name of the connection factory configuration to be parsed.
496     * @param trustManager
497     *            The trust manager to use for secure connection. Can be {@code null}
498     *            to use the default JVM trust manager.
499     * @param keyManager
500     *            The key manager to use for secure connection. Can be {@code null}
501     *            to use the default JVM key manager.
502     * @param providerClassLoader
503     *         The {@link ClassLoader} used to fetch the {@link org.forgerock.opendj.ldap.spi.TransportProvider}. This
504     *         can be useful in OSGI environments.
505     * @return A new connection factory using the provided JSON configuration.
506     * @throws IllegalArgumentException
507     *         If the configuration is invalid.
508     */
509    public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
510                                                               final String name,
511                                                               final TrustManager trustManager,
512                                                               final X509KeyManager keyManager,
513                                                               final ClassLoader providerClassLoader) {
514        final JsonValue normalizedConfiguration = normalizeConnectionFactory(configuration, name, 0);
515        return configureConnectionFactory(normalizedConfiguration, trustManager, keyManager, providerClassLoader);
516    }
517
518    /**
519     * Creates a new connection factory using the named configuration in the provided JSON list of factory
520     * configurations. See the sample configuration file for a detailed description of its content.
521     *
522     * @param configuration
523     *         The JSON configuration.
524     * @param name
525     *         The name of the connection factory configuration to be parsed.
526     * @param trustManager
527     *            The trust manager to use for secure connection. Can be {@code null}
528     *            to use the default JVM trust manager.
529     * @param keyManager
530     *            The key manager to use for secure connection. Can be {@code null}
531     *            to use the default JVM key manager.
532     * @return A new connection factory using the provided JSON configuration.
533     * @throws IllegalArgumentException
534     *         If the configuration is invalid.
535     */
536    public static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
537                                                               final String name,
538                                                               final TrustManager trustManager,
539                                                               final X509KeyManager keyManager) {
540        return configureConnectionFactory(configuration, name, trustManager, keyManager, null);
541    }
542
543    private static ConnectionFactory configureConnectionFactory(final JsonValue configuration,
544                                                                final TrustManager trustManager,
545                                                                final X509KeyManager keyManager,
546                                                                final ClassLoader providerClassLoader) {
547        final long heartBeatIntervalSeconds = configuration.get("heartBeatIntervalSeconds").defaultTo(30L).asLong();
548        final Duration heartBeatInterval = duration(Math.max(heartBeatIntervalSeconds, 1L), TimeUnit.SECONDS);
549
550        final long heartBeatTimeoutMillis = configuration.get("heartBeatTimeoutMilliSeconds").defaultTo(500L).asLong();
551        final Duration heartBeatTimeout = duration(Math.max(heartBeatTimeoutMillis, 100L), TimeUnit.MILLISECONDS);
552
553        final Options options = Options.defaultOptions()
554                                       .set(TRANSPORT_PROVIDER_CLASS_LOADER, providerClassLoader)
555                                       .set(HEARTBEAT_ENABLED, true)
556                                       .set(HEARTBEAT_INTERVAL, heartBeatInterval)
557                                       .set(HEARTBEAT_TIMEOUT, heartBeatTimeout)
558                                       .set(LOAD_BALANCER_MONITORING_INTERVAL, heartBeatInterval);
559
560        // Parse pool parameters,
561        final int connectionPoolSize =
562                Math.max(configuration.get("connectionPoolSize").defaultTo(10).asInteger(), 1);
563
564        // Parse authentication parameters.
565        if (configuration.isDefined("authentication")) {
566            final JsonValue authn = configuration.get("authentication");
567            if (authn.isDefined("simple")) {
568                final JsonValue simple = authn.get("simple");
569                final BindRequest bindRequest =
570                        Requests.newSimpleBindRequest(simple.get("bindDn").required().asString(),
571                                                      simple.get("bindPassword").required().asString().toCharArray());
572                options.set(AUTHN_BIND_REQUEST, bindRequest);
573            } else {
574                throw new LocalizedIllegalArgumentException(ERR_CONFIG_INVALID_AUTHENTICATION.get());
575            }
576        }
577
578        // Parse SSL/StartTLS parameters.
579        final ConnectionSecurity connectionSecurity = configuration.get("connectionSecurity")
580                                                                   .defaultTo(ConnectionSecurity.NONE)
581                                                                   .as(enumConstant(ConnectionSecurity.class));
582        if (connectionSecurity != ConnectionSecurity.NONE) {
583            try {
584                // Configure SSL.
585                final SSLContextBuilder builder = new SSLContextBuilder();
586                builder.setTrustManager(trustManager);
587                final String sslCertAlias = configuration.get("sslCertAlias").asString();
588                builder.setKeyManager(sslCertAlias != null
589                                              ? useSingleCertificate(sslCertAlias, keyManager)
590                                              : keyManager);
591                options.set(SSL_CONTEXT, builder.getSSLContext());
592                options.set(SSL_USE_STARTTLS, connectionSecurity == ConnectionSecurity.STARTTLS);
593            } catch (GeneralSecurityException e) {
594                // Rethrow as unchecked exception.
595                throw new IllegalArgumentException(e);
596            }
597        }
598
599        // Parse primary data center.
600        final JsonValue primaryLdapServers = configuration.get("primaryLdapServers");
601        if (!primaryLdapServers.isList() || primaryLdapServers.size() == 0) {
602            throw new IllegalArgumentException("No primaryLdapServers");
603        }
604        final ConnectionFactory primary = parseLdapServers(primaryLdapServers, connectionPoolSize, options);
605
606        // Parse secondary data center(s).
607        final JsonValue secondaryLdapServers = configuration.get("secondaryLdapServers");
608        ConnectionFactory secondary = null;
609        if (secondaryLdapServers.isList()) {
610            if (secondaryLdapServers.size() > 0) {
611                secondary = parseLdapServers(secondaryLdapServers, connectionPoolSize, options);
612            }
613        } else if (!secondaryLdapServers.isNull()) {
614            throw new LocalizedIllegalArgumentException(ERR_CONFIG_INVALID_SECONDARY_LDAP_SERVER.get());
615        }
616
617        // Create fail-over.
618        if (secondary != null) {
619            return newFailoverLoadBalancer(asList(primary, secondary), options);
620        } else {
621            return primary;
622        }
623    }
624
625    private static JsonValue normalizeConnectionFactory(final JsonValue configuration,
626                                                        final String name, final int depth) {
627        // Protect against infinite recursion in the configuration.
628        if (depth > 100) {
629            throw new LocalizedIllegalArgumentException(ERR_CONFIG_SERVER_CIRCULAR_DEPENDENCIES.get(name));
630        }
631
632        final JsonValue current = configuration.get(name).required();
633        if (current.isDefined("inheritFrom")) {
634            // Inherit missing fields from inherited configuration.
635            final JsonValue parent =
636                    normalizeConnectionFactory(configuration,
637                                               current.get("inheritFrom").asString(), depth + 1);
638            final Map<String, Object> normalized = new LinkedHashMap<>(parent.asMap());
639            normalized.putAll(current.asMap());
640            normalized.remove("inheritFrom");
641            return new JsonValue(normalized);
642        } else {
643            // No normalization required.
644            return current;
645        }
646    }
647
648    private static ConnectionFactory parseLdapServers(JsonValue config, int poolSize, Options options) {
649        final List<ConnectionFactory> servers = new ArrayList<>(config.size());
650        for (final JsonValue server : config) {
651            final String host = server.get("hostname").required().asString();
652            final int port = server.get("port").required().asInteger();
653            final ConnectionFactory factory = new LDAPConnectionFactory(host, port, options);
654            if (poolSize > 1) {
655                servers.add(newCachedConnectionPool(factory, 0, poolSize, 60L, TimeUnit.SECONDS));
656            } else {
657                servers.add(factory);
658            }
659        }
660        if (servers.size() > 1) {
661            return newRoundRobinLoadBalancer(servers, options);
662        } else {
663            return servers.get(0);
664        }
665    }
666
667    private Rest2LdapJsonConfigurator() {
668        // Prevent instantiation.
669    }
670}