1 /**
2 	MongoDB client connection settings.
3 
4 	Copyright: © 2012-2016 Sönke Ludwig
5 	License: Subject to the terms of the MIT license, as written in the included LICENSE.txt file.
6 	Authors: Sönke Ludwig
7 */
8 module vibe.db.mongo.settings;
9 
10 import vibe.core.log;
11 import vibe.data.bson;
12 deprecated import vibe.db.mongo.flags : QueryFlags;
13 import vibe.inet.webform;
14 
15 import core.time;
16 import std.conv : to;
17 import std.digest : toHexString;
18 import std.digest.md : md5Of;
19 import std.algorithm : splitter, startsWith;
20 import std.string : icmp, indexOf, toLower;
21 
22 
23 /**
24  * Parses the given string as a mongodb URL. The URL must be in the form documented at
25  * $(LINK http://www.mongodb.org/display/DOCS/Connections) which is:
26  *
27  * mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]]
28  *
29  * Returns: true if the URL was successfully parsed. False if the URL can not be parsed.
30  *
31  * If the URL is successfully parsed the MongoClientSettings instance will contain the parsed config.
32  * If the URL is not successfully parsed the information in the MongoClientSettings instance may be
33  * incomplete and should not be used.
34  */
35 bool parseMongoDBUrl(out MongoClientSettings cfg, string url)
36 @safe {
37 	import std.exception : enforce;
38 
39 	cfg = new MongoClientSettings();
40 
41 	string tmpUrl = url[0..$]; // Slice of the URL (not a copy)
42 
43 	if( !startsWith(tmpUrl, "mongodb://") )
44 	{
45 		return false;
46 	}
47 
48 	// Reslice to get rid of 'mongodb://'
49 	tmpUrl = tmpUrl[10..$];
50 
51 	auto authIndex = tmpUrl.indexOf('@');
52 	sizediff_t hostIndex = 0; // Start of the host portion of the URL.
53 
54 	// Parse out the username and optional password.
55 	if( authIndex != -1 )
56 	{
57 		// Set the host start to after the '@'
58 		hostIndex = authIndex + 1;
59 		string password;
60 
61 		auto colonIndex = tmpUrl[0..authIndex].indexOf(':');
62 		if(colonIndex != -1)
63 		{
64 			cfg.username = tmpUrl[0..colonIndex];
65 			password = tmpUrl[colonIndex + 1 .. authIndex];
66 		} else {
67 			cfg.username = tmpUrl[0..authIndex];
68 		}
69 
70 		// Make sure the username is not empty. If it is then the parse failed.
71 		if(cfg.username.length == 0)
72 		{
73 			return false;
74 		}
75 
76 		cfg.digest = MongoClientSettings.makeDigest(cfg.username, password);
77 	}
78 
79 	auto slashIndex = tmpUrl[hostIndex..$].indexOf("/");
80 	if( slashIndex == -1 ) slashIndex = tmpUrl.length;
81 	else slashIndex += hostIndex;
82 
83 	// Parse the hosts section.
84 	try
85 	{
86 		foreach(entry; splitter(tmpUrl[hostIndex..slashIndex], ","))
87 		{
88 			auto hostPort = splitter(entry, ":");
89 			string host = hostPort.front;
90 			hostPort.popFront();
91 			ushort port = MongoClientSettings.defaultPort;
92 			if (!hostPort.empty) {
93 				port = to!ushort(hostPort.front);
94 				hostPort.popFront();
95 			}
96 			enforce(hostPort.empty, "Host specifications are expected to be of the form \"HOST:PORT,HOST:PORT,...\".");
97 			cfg.hosts ~= MongoHost(host, port);
98 		}
99 	} catch (Exception e) {
100 		return  false; // Probably failed converting the port to ushort.
101 	}
102 
103 	// If we couldn't parse a host we failed.
104 	if(cfg.hosts.length == 0)
105 	{
106 		return false;
107 	}
108 
109 	if(slashIndex == tmpUrl.length)
110 	{
111 		// We're done parsing.
112 		return true;
113 	}
114 
115 	auto queryIndex = tmpUrl[slashIndex..$].indexOf("?");
116 	if(queryIndex == -1){
117 		// No query string. Remaining string is the database
118 		queryIndex = tmpUrl.length;
119 	} else {
120 		queryIndex += slashIndex;
121 	}
122 
123 	cfg.database = tmpUrl[slashIndex+1..queryIndex];
124 	if(queryIndex != tmpUrl.length)
125 	{
126 		FormFields options;
127 		parseURLEncodedForm(tmpUrl[queryIndex+1 .. $], options);
128 		foreach (option, value; options.byKeyValue) {
129 			bool setBool(ref bool dst)
130 			{
131 				try {
132 					dst = to!bool(value);
133 					return true;
134 				} catch( Exception e ){
135 					logError("Value for '%s' must be 'true' or 'false' but was '%s'.", option, value);
136 					return false;
137 				}
138 			}
139 
140 			bool setLong(ref long dst)
141 			{
142 				try {
143 					dst = to!long(value);
144 					return true;
145 				} catch( Exception e ){
146 					logError("Value for '%s' must be an integer but was '%s'.", option, value);
147 					return false;
148 				}
149 			}
150 
151 			bool setMsecs(ref Duration dst)
152 			{
153 				try {
154 					dst = to!long(value).msecs;
155 					return true;
156 				} catch( Exception e ){
157 					logError("Value for '%s' must be an integer but was '%s'.", option, value);
158 					return false;
159 				}
160 			}
161 
162 			void warnNotImplemented()
163 			{
164 				logDiagnostic("MongoDB option %s not yet implemented.", option);
165 			}
166 
167 			switch( option.toLower() ){
168 				import std.string : split;
169 
170 				default: logWarn("Unknown MongoDB option %s", option); break;
171 				case "appname": cfg.appName = value; break;
172 				case "replicaset": cfg.replicaSet = value; warnNotImplemented(); break;
173 				case "safe": setBool(cfg.safe); break;
174 				case "fsync": setBool(cfg.fsync); break;
175 				case "journal": setBool(cfg.journal); break;
176 				case "connecttimeoutms": setMsecs(cfg.connectTimeout); break;
177 				case "sockettimeoutms": setMsecs(cfg.socketTimeout); break;
178 				case "tls": setBool(cfg.ssl); break;
179 				case "ssl": setBool(cfg.ssl); break;
180 				case "sslverifycertificate": setBool(cfg.sslverifycertificate); break;
181 				case "authmechanism": cfg.authMechanism = parseAuthMechanism(value); break;
182 				case "authmechanismproperties": cfg.authMechanismProperties = value.split(","); warnNotImplemented(); break;
183 				case "authsource": cfg.authSource = value; break;
184 				case "wtimeoutms": setLong(cfg.wTimeoutMS); break;
185 				case "w":
186 					try {
187 						if(icmp(value, "majority") == 0){
188 							cfg.w = Bson("majority");
189 						} else {
190 							cfg.w = Bson(to!long(value));
191 						}
192 					} catch (Exception e) {
193 						logError("Invalid w value: [%s] Should be an integer number or 'majority'", value);
194 					}
195 				break;
196 			}
197 		}
198 
199 		/* Some m_settings imply safe. If they are set, set safe to true regardless
200 		 * of what it was set to in the URL string
201 		 */
202 		if( (cfg.w != Bson.init) || (cfg.wTimeoutMS != long.init) ||
203 				cfg.journal 	 || cfg.fsync )
204 		{
205 			cfg.safe = true;
206 		}
207 	}
208 
209 	return true;
210 }
211 
212 /* Test for parseMongoDBUrl */
213 unittest
214 {
215 	MongoClientSettings cfg;
216 
217 	assert(parseMongoDBUrl(cfg, "mongodb://localhost"));
218 	assert(cfg.hosts.length == 1);
219 	assert(cfg.database == "");
220 	assert(cfg.hosts[0].name == "localhost");
221 	assert(cfg.hosts[0].port == 27017);
222 	assert(cfg.replicaSet == "");
223 	assert(cfg.safe == false);
224 	assert(cfg.w == Bson.init);
225 	assert(cfg.wTimeoutMS == long.init);
226 	assert(cfg.fsync == false);
227 	assert(cfg.journal == false);
228 	assert(cfg.connectTimeoutMS == 10_000);
229 	assert(cfg.socketTimeoutMS == long.init);
230 	assert(cfg.ssl == bool.init);
231 	assert(cfg.sslverifycertificate == true);
232 
233 	cfg = MongoClientSettings.init;
234 	assert(parseMongoDBUrl(cfg, "mongodb://fred:foobar@localhost"));
235 	assert(cfg.username == "fred");
236 	//assert(cfg.password == "foobar");
237 	assert(cfg.digest == MongoClientSettings.makeDigest("fred", "foobar"));
238 	assert(cfg.hosts.length == 1);
239 	assert(cfg.database == "");
240 	assert(cfg.hosts[0].name == "localhost");
241 	assert(cfg.hosts[0].port == 27017);
242 
243 	cfg = MongoClientSettings.init;
244 	assert(parseMongoDBUrl(cfg, "mongodb://fred:@localhost/baz"));
245 	assert(cfg.username == "fred");
246 	//assert(cfg.password == "");
247 	assert(cfg.digest == MongoClientSettings.makeDigest("fred", ""));
248 	assert(cfg.database == "baz");
249 	assert(cfg.hosts.length == 1);
250 	assert(cfg.hosts[0].name == "localhost");
251 	assert(cfg.hosts[0].port == 27017);
252 
253 	cfg = MongoClientSettings.init;
254 	assert(parseMongoDBUrl(cfg, "mongodb://host1,host2,host3/?safe=true&w=2&wtimeoutMS=2000&ssl=true&sslverifycertificate=false"));
255 	assert(cfg.username == "");
256 	//assert(cfg.password == "");
257 	assert(cfg.digest == "");
258 	assert(cfg.database == "");
259 	assert(cfg.hosts.length == 3);
260 	assert(cfg.hosts[0].name == "host1");
261 	assert(cfg.hosts[0].port == 27017);
262 	assert(cfg.hosts[1].name == "host2");
263 	assert(cfg.hosts[1].port == 27017);
264 	assert(cfg.hosts[2].name == "host3");
265 	assert(cfg.hosts[2].port == 27017);
266 	assert(cfg.safe == true);
267 	assert(cfg.w == Bson(2L));
268 	assert(cfg.wTimeoutMS == 2000);
269 	assert(cfg.ssl == true);
270 	assert(cfg.sslverifycertificate == false);
271 
272 	cfg = MongoClientSettings.init;
273 	assert(parseMongoDBUrl(cfg,
274 				"mongodb://fred:flinstone@host1.example.com,host2.other.example.com:27108,host3:"
275 				~ "27019/mydb?journal=true;fsync=true;connectTimeoutms=1500;sockettimeoutMs=1000;w=majority"));
276 	assert(cfg.username == "fred");
277 	//assert(cfg.password == "flinstone");
278 	assert(cfg.digest == MongoClientSettings.makeDigest("fred", "flinstone"));
279 	assert(cfg.database == "mydb");
280 	assert(cfg.hosts.length == 3);
281 	assert(cfg.hosts[0].name == "host1.example.com");
282 	assert(cfg.hosts[0].port == 27017);
283 	assert(cfg.hosts[1].name == "host2.other.example.com");
284 	assert(cfg.hosts[1].port == 27108);
285 	assert(cfg.hosts[2].name == "host3");
286 	assert(cfg.hosts[2].port == 27019);
287 	assert(cfg.fsync == true);
288 	assert(cfg.journal == true);
289 	assert(cfg.connectTimeoutMS == 1500);
290 	assert(cfg.socketTimeoutMS == 1000);
291 	assert(cfg.w == Bson("majority"));
292 	assert(cfg.safe == true);
293 
294 	// Invalid URLs - these should fail to parse
295 	cfg = MongoClientSettings.init;
296 	assert(! (parseMongoDBUrl(cfg, "localhost:27018")));
297 	assert(! (parseMongoDBUrl(cfg, "http://blah")));
298 	assert(! (parseMongoDBUrl(cfg, "mongodb://@localhost")));
299 	assert(! (parseMongoDBUrl(cfg, "mongodb://:thepass@localhost")));
300 	assert(! (parseMongoDBUrl(cfg, "mongodb://:badport/")));
301 
302 	assert(parseMongoDBUrl(cfg, "mongodb://me:sl$ash/w0+rd@localhost"));
303 	assert(cfg.digest == MongoClientSettings.makeDigest("me", "sl$ash/w0+rd"));
304 	assert(cfg.hosts.length == 1);
305 	assert(cfg.hosts[0].name == "localhost");
306 	assert(cfg.hosts[0].port == 27017);
307 	assert(parseMongoDBUrl(cfg, "mongodb://me:sl$ash/w0+rd@localhost/mydb"));
308 	assert(cfg.digest == MongoClientSettings.makeDigest("me", "sl$ash/w0+rd"));
309 	assert(cfg.database == "mydb");
310 	assert(cfg.hosts.length == 1);
311 	assert(cfg.hosts[0].name == "localhost");
312 	assert(cfg.hosts[0].port == 27017);
313 }
314 
315 /**
316  * Describes a vibe.d supported authentication mechanism to use on client
317  * connection to a MongoDB server.
318  */
319 enum MongoAuthMechanism
320 {
321 	/**
322 	 * Use no auth mechanism. If a digest or ssl certificate is given this
323 	 * defaults to trying the recommend auth mechanisms depending on server
324 	 * version and input parameters.
325 	 */
326 	none,
327 
328 	/**
329 	 * Use SCRAM-SHA-1 as defined in [RFC 5802](http://tools.ietf.org/html/rfc5802)
330 	 *
331 	 * This is the default when a password is provided. In the future other
332 	 * scram algorithms may be implemented and selectable through these values.
333 	 *
334 	 * MongoDB: 3.0–
335 	 */
336 	scramSHA1,
337 
338 	/**
339 	 * Forces login through the legacy MONGODB-CR authentication mechanism. This
340 	 * mechanism is a nonce and MD5 based system.
341 	 *
342 	 * MongoDB: 1.4–4.0 (deprecated 3.0)
343 	 */
344 	mongoDBCR,
345 
346 	/**
347 	 * Use an X.509 certificate to authenticate. Only works if digest is set to
348 	 * null or empty string in the MongoClientSettings.
349 	 *
350 	 * MongoDB: 2.6–
351 	 */
352 	mongoDBX509
353 }
354 
355 private MongoAuthMechanism parseAuthMechanism(string str)
356 @safe {
357 	switch (str) {
358 		case "SCRAM-SHA-1": return MongoAuthMechanism.scramSHA1;
359 		case "MONGODB-CR": return MongoAuthMechanism.mongoDBCR;
360 		case "MONGODB-X509": return MongoAuthMechanism.mongoDBX509;
361 		default: throw new Exception("Auth mechanism \"" ~ str ~ "\" not supported");
362 	}
363 }
364 
365 /**
366  * See_Also: $(LINK https://docs.mongodb.com/manual/reference/connection-string/#connections-connection-options)
367  */
368 class MongoClientSettings
369 {
370 	/// Gets the default port used for MongoDB connections
371 	enum ushort defaultPort = 27017;
372 
373 	/**
374 	 * If set to non-empty string, use this username to try to authenticate with
375 	 * to the database. Only has an effect if digest or sslPEMKeyFile is set too
376 	 *
377 	 * Use $(LREF authenticatePassword) or $(LREF authenticateSSL) to
378 	 * automatically fill this.
379 	 */
380 	string username;
381 
382 	/**
383 	 * The password hashed as MongoDB digest as returned by $(LREF makeDigest).
384 	 *
385 	 * **DISCOURAGED** to fill this manually as future authentication mechanisms
386 	 * may use other digest algorithms.
387 	 *
388 	 * Use $(LREF authenticatePassword) to automatically fill this.
389 	 */
390 	string digest;
391 
392 	/**
393 	 * Amount of maximum simultaneous connections to have open at the same time.
394 	 *
395 	 * Every MongoDB call may allocate a new connection if no previous ones are
396 	 * available and there is no connection associated with the calling Fiber.
397 	 */
398 	uint maxConnections = uint.max;
399 
400 	/**
401 	 * MongoDB hosts to try to connect to.
402 	 *
403 	 * Bugs: currently only a connection to the first host is attempted, more
404 	 * hosts are simply ignored.
405 	 */
406 	MongoHost[] hosts;
407 
408 	/**
409 	 * Default auth database to operate on, otherwise operating on special
410 	 * "admin" database for all MongoDB authentication commands.
411 	 */
412 	string database;
413 
414 	deprecated("unused since at least before v3.6") QueryFlags defQueryFlags = QueryFlags.None;
415 
416 	/**
417 	 * Specifies the name of the replica set, if the mongod is a member of a
418 	 * replica set.
419 	 *
420 	 * Bugs: Not yet implemented
421 	 */
422 	string replicaSet;
423 
424 	/**
425 	 * Automatically check for errors when operating on collections and throw a
426 	 * $(REF MongoDBException, vibe,db,mongo,connection) in case of errors.
427 	 *
428 	 * Automatically set if either:
429 	 * * the "w" (write concern) parameter is set
430 	 * * the "wTimeoutMS" parameter is set
431 	 * * journal is true
432 	 */
433 	bool safe;
434 
435 	/**
436 	 * Requests acknowledgment that write operations have propagated to a
437 	 * specified number of mongod instances (number) or to mongod instances with
438 	 * specified tags (string) or "majority" for calculated majority.
439 	 *
440 	 * See_Also: write concern [w Option](https://docs.mongodb.com/manual/reference/write-concern/#wc-w).
441 	 */
442 	Bson w; // Either a number or the string 'majority'
443 
444 	/**
445 	 * Time limit for the w option to prevent write operations from blocking
446 	 * indefinitely.
447 	 *
448 	 * See_Also: $(LREF w)
449 	 */
450 	long wTimeoutMS;
451 
452 	// undocumented feature in no documentation of >=MongoDB 2.2 ?!
453 	bool fsync;
454 
455 	/**
456 	 * Requests acknowledgment that write operations have been written to the
457 	 * [on-disk journal](https://docs.mongodb.com/manual/core/journaling/).
458 	 *
459 	 * See_Also: write concern [j Option](https://docs.mongodb.com/manual/reference/write-concern/#wc-j).
460 	 */
461 	bool journal;
462 
463 	/**
464 	 * The time to attempt a connection before timing out.
465 	 */
466 	Duration connectTimeout = 10.seconds;
467 
468 	/// ditto
469 	long connectTimeoutMS() const @property
470 	@safe {
471 		return connectTimeout.total!"msecs";
472 	}
473 
474 	/// ditto
475 	void connectTimeoutMS(long ms) @property
476 	@safe {
477 		connectTimeout = ms.msecs;
478 	}
479 
480 	/**
481 	 * The time to attempt a send or receive on a socket before the attempt
482 	 * times out.
483 	 *
484 	 * Bugs: Not implemented for sending
485 	 */
486 	Duration socketTimeout = Duration.zero;
487 
488 	/// ditto
489 	long socketTimeoutMS() const @property
490 	@safe {
491 		return socketTimeout.total!"msecs";
492 	}
493 
494 	/// ditto
495 	void socketTimeoutMS(long ms) @property
496 	@safe {
497 		socketTimeout = ms.msecs;
498 	}
499 
500 	/**
501 	 * Enables or disables TLS/SSL for the connection.
502 	 */
503 	bool ssl;
504 
505 	/**
506 	 * Can be set to false to disable TLS peer validation to allow self signed
507 	 * certificates.
508 	 *
509 	 * This mode is discouraged and should ONLY be used in development.
510 	 */
511 	bool sslverifycertificate = true;
512 
513 	/**
514 	 * Path to a certificate with private key and certificate chain to connect
515 	 * with.
516 	 */
517 	string sslPEMKeyFile;
518 
519 	/**
520 	 * Path to a certificate authority file for verifying the remote
521 	 * certificate.
522 	 */
523 	string sslCAFile;
524 
525 	/** 
526 	 * Specify the database name associated with the user's credentials. If
527 	 * `authSource` is unspecified, `authSource` defaults to the `defaultauthdb`
528 	 * specified in the connection string. If `defaultauthdb` is unspecified,
529 	 * then `authSource` defaults to `admin`.
530 	 *
531 	 * The `PLAIN` (LDAP), `GSSAPI` (Kerberos), and `MONGODB-AWS` (IAM)
532 	 * authentication mechanisms require that `authSource` be set to `$external`,
533 	 * as these mechanisms delegate credential storage to external services.
534 	 *
535 	 * Ignored if no username is provided.
536 	 */
537 	string authSource;
538 
539 	/**
540 	 * Use the given authentication mechanism when connecting to the server. If
541 	 * unsupported by the server, throw a MongoAuthException.
542 	 *
543 	 * If set to none, but digest or sslPEMKeyFile are set, this automatically
544 	 * determines a suitable authentication mechanism based on server version.
545 	 */
546 	MongoAuthMechanism authMechanism;
547 
548 	/** 
549 	 * Specify properties for the specified authMechanism as a comma-separated
550 	 * list of colon-separated key-value pairs.
551 	 *
552 	 * Currently none are used by the vibe.d Mongo driver.
553 	 */
554 	string[] authMechanismProperties;
555 
556 	/**
557 	 * Application name for the connection information when connected.
558 	 *
559 	 * The application name is printed to the mongod logs upon establishing the
560 	 * connection. It is also recorded in the slow query logs and profile
561 	 * collections.
562 	 */
563 	string appName;
564 
565 	/**
566 	 * Generates a digest string which can be used for authentication by setting
567 	 * the username and digest members.
568 	 *
569 	 * Use $(LREF authenticate) to automatically configure username and digest.
570 	 */
571 	static pure string makeDigest(string username, string password)
572 	@safe {
573 		return md5Of(username ~ ":mongo:" ~ password).toHexString().idup.toLower();
574 	}
575 
576 	/**
577 	 * Sets the username and the digest string in this MongoClientSettings
578 	 * instance.
579 	 */
580 	void authenticatePassword(string username, string password)
581 	@safe {
582 		this.username = username;
583 		this.digest = MongoClientSettings.makeDigest(username, password);
584 	}
585 
586 	/**
587 	 * Sets ssl, the username, the PEM key file and the trusted CA file in this
588 	 * MongoClientSettings instance.
589 	 *
590 	 * Params:
591 	 *   username = The username as provided in the cert file like
592 	 *   `"C=IS,ST=Reykjavik,L=Reykjavik,O=MongoDB,OU=Drivers,CN=client"`.
593 	 *
594 	 *   The username can be blank if connecting to MongoDB 3.4 or above.
595 	 *
596 	 *   sslPEMKeyFile = Path to a certificate with private key and certificate
597 	 *   chain to connect with.
598 	 *
599 	 *   sslCAFile = Optional path to a trusted certificate authority file for
600 	 *   verifying the remote certificate.
601 	 */
602 	void authenticateSSL(string username, string sslPEMKeyFile, string sslCAFile = null)
603 	@safe {
604 		this.ssl = true;
605 		this.digest = null;
606 		this.username = username;
607 		this.sslPEMKeyFile = sslPEMKeyFile;
608 		this.sslCAFile = sslCAFile;
609 	}
610 
611 	/** 
612 	 * Resolves the database to run authentication commands on.
613 	 * (authSource if set, otherwise the URI's database if set, otherwise "admin")
614 	 */
615 	string getAuthDatabase()
616 	@safe @nogc nothrow pure const return {
617 		if (authSource.length)
618 			return authSource;
619 		else if (database.length)
620 			return database;
621 		else
622 			return "admin";
623 	}
624 }
625 
626 /// Describes a host we might be able to connect to
627 struct MongoHost
628 {
629 	/// The host name or IP address of the remote MongoDB server.
630 	string name;
631 	/// The port of the MongoDB server. See `MongoClientSettings.defaultPort`.
632 	ushort port;
633 }