1 /** 2 MongoDB client connection settings. 3 4 Copyright: © 2012-2016 RejectedSoftware e.K. 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 import vibe.db.mongo.flags : QueryFlags; 13 import vibe.inet.webform; 14 15 import std.conv : to; 16 import std.digest.digest : toHexString; 17 import std.digest.md : md5Of; 18 import std.algorithm : splitter, startsWith; 19 import std.string : icmp, indexOf, toLower; 20 21 22 /** 23 * Parses the given string as a mongodb URL. The URL must be in the form documented at 24 * $(LINK http://www.mongodb.org/display/DOCS/Connections) which is: 25 * 26 * mongodb://[username:password@]host1[:port1][,host2[:port2],...[,hostN[:portN]]][/[database][?options]] 27 * 28 * Returns: true if the URL was successfully parsed. False if the URL can not be parsed. 29 * 30 * If the URL is successfully parsed the MongoClientSettings instance will contain the parsed config. 31 * If the URL is not successfully parsed the information in the MongoClientSettings instance may be 32 * incomplete and should not be used. 33 */ 34 bool parseMongoDBUrl(out MongoClientSettings cfg, string url) 35 @safe { 36 import std.exception : enforce; 37 38 cfg = new MongoClientSettings(); 39 40 string tmpUrl = url[0..$]; // Slice of the url (not a copy) 41 42 if( !startsWith(tmpUrl, "mongodb://") ) 43 { 44 return false; 45 } 46 47 // Reslice to get rid of 'mongodb://' 48 tmpUrl = tmpUrl[10..$]; 49 50 auto authIndex = tmpUrl.indexOf('@'); 51 sizediff_t hostIndex = 0; // Start of the host portion of the URL. 52 53 // Parse out the username and optional password. 54 if( authIndex != -1 ) 55 { 56 // Set the host start to after the '@' 57 hostIndex = authIndex + 1; 58 string password; 59 60 auto colonIndex = tmpUrl[0..authIndex].indexOf(':'); 61 if(colonIndex != -1) 62 { 63 cfg.username = tmpUrl[0..colonIndex]; 64 password = tmpUrl[colonIndex + 1 .. authIndex]; 65 } else { 66 cfg.username = tmpUrl[0..authIndex]; 67 } 68 69 // Make sure the username is not empty. If it is then the parse failed. 70 if(cfg.username.length == 0) 71 { 72 return false; 73 } 74 75 cfg.digest = MongoClientSettings.makeDigest(cfg.username, password); 76 } 77 78 auto slashIndex = tmpUrl[hostIndex..$].indexOf("/"); 79 if( slashIndex == -1 ) slashIndex = tmpUrl.length; 80 else slashIndex += hostIndex; 81 82 // Parse the hosts section. 83 try 84 { 85 foreach(entry; splitter(tmpUrl[hostIndex..slashIndex], ",")) 86 { 87 auto hostPort = splitter(entry, ":"); 88 string host = hostPort.front; 89 hostPort.popFront(); 90 ushort port = MongoClientSettings.defaultPort; 91 if (!hostPort.empty) { 92 port = to!ushort(hostPort.front); 93 hostPort.popFront(); 94 } 95 enforce(hostPort.empty, "Host specifications are expected to be of the form \"HOST:PORT,HOST:PORT,...\"."); 96 cfg.hosts ~= MongoHost(host, port); 97 } 98 } catch (Exception e) { 99 return false; // Probably failed converting the port to ushort. 100 } 101 102 // If we couldn't parse a host we failed. 103 if(cfg.hosts.length == 0) 104 { 105 return false; 106 } 107 108 if(slashIndex == tmpUrl.length) 109 { 110 // We're done parsing. 111 return true; 112 } 113 114 auto queryIndex = tmpUrl[slashIndex..$].indexOf("?"); 115 if(queryIndex == -1){ 116 // No query string. Remaining string is the database 117 queryIndex = tmpUrl.length; 118 } else { 119 queryIndex += slashIndex; 120 } 121 122 cfg.database = tmpUrl[slashIndex+1..queryIndex]; 123 if(queryIndex != tmpUrl.length) 124 { 125 FormFields options; 126 parseURLEncodedForm(tmpUrl[queryIndex+1 .. $], options); 127 foreach (option, value; options) { 128 bool setBool(ref bool dst) 129 { 130 try { 131 dst = to!bool(value); 132 return true; 133 } catch( Exception e ){ 134 logError("Value for '%s' must be 'true' or 'false' but was '%s'.", option, value); 135 return false; 136 } 137 } 138 139 bool setLong(ref long dst) 140 { 141 try { 142 dst = to!long(value); 143 return true; 144 } catch( Exception e ){ 145 logError("Value for '%s' must be an integer but was '%s'.", option, value); 146 return false; 147 } 148 } 149 150 void warnNotImplemented() 151 { 152 logDiagnostic("MongoDB option %s not yet implemented.", option); 153 } 154 155 switch( option.toLower() ){ 156 default: logWarn("Unknown MongoDB option %s", option); break; 157 case "slaveok": bool v; if( setBool(v) && v ) cfg.defQueryFlags |= QueryFlags.SlaveOk; break; 158 case "replicaset": cfg.replicaSet = value; warnNotImplemented(); break; 159 case "safe": setBool(cfg.safe); break; 160 case "fsync": setBool(cfg.fsync); break; 161 case "journal": setBool(cfg.journal); break; 162 case "connecttimeoutms": setLong(cfg.connectTimeoutMS); warnNotImplemented(); break; 163 case "sockettimeoutms": setLong(cfg.socketTimeoutMS); warnNotImplemented(); break; 164 case "ssl": setBool(cfg.ssl); break; 165 case "sslverifycertificate": setBool(cfg.sslverifycertificate); break; 166 case "authmechanism": cfg.authMechanism = parseAuthMechanism(value); break; 167 case "wtimeoutms": setLong(cfg.wTimeoutMS); break; 168 case "w": 169 try { 170 if(icmp(value, "majority") == 0){ 171 cfg.w = Bson("majority"); 172 } else { 173 cfg.w = Bson(to!long(value)); 174 } 175 } catch (Exception e) { 176 logError("Invalid w value: [%s] Should be an integer number or 'majority'", value); 177 } 178 break; 179 } 180 } 181 182 /* Some m_settings imply safe. If they are set, set safe to true regardless 183 * of what it was set to in the URL string 184 */ 185 if( (cfg.w != Bson.init) || (cfg.wTimeoutMS != long.init) || 186 cfg.journal || cfg.fsync ) 187 { 188 cfg.safe = true; 189 } 190 } 191 192 return true; 193 } 194 195 /* Test for parseMongoDBUrl */ 196 unittest 197 { 198 MongoClientSettings cfg; 199 200 assert(parseMongoDBUrl(cfg, "mongodb://localhost")); 201 assert(cfg.hosts.length == 1); 202 assert(cfg.database == ""); 203 assert(cfg.hosts[0].name == "localhost"); 204 assert(cfg.hosts[0].port == 27017); 205 assert(cfg.defQueryFlags == QueryFlags.None); 206 assert(cfg.replicaSet == ""); 207 assert(cfg.safe == false); 208 assert(cfg.w == Bson.init); 209 assert(cfg.wTimeoutMS == long.init); 210 assert(cfg.fsync == false); 211 assert(cfg.journal == false); 212 assert(cfg.connectTimeoutMS == long.init); 213 assert(cfg.socketTimeoutMS == long.init); 214 assert(cfg.ssl == bool.init); 215 assert(cfg.sslverifycertificate == true); 216 217 cfg = MongoClientSettings.init; 218 assert(parseMongoDBUrl(cfg, "mongodb://fred:foobar@localhost")); 219 assert(cfg.username == "fred"); 220 //assert(cfg.password == "foobar"); 221 assert(cfg.digest == MongoClientSettings.makeDigest("fred", "foobar")); 222 assert(cfg.hosts.length == 1); 223 assert(cfg.database == ""); 224 assert(cfg.hosts[0].name == "localhost"); 225 assert(cfg.hosts[0].port == 27017); 226 227 cfg = MongoClientSettings.init; 228 assert(parseMongoDBUrl(cfg, "mongodb://fred:@localhost/baz")); 229 assert(cfg.username == "fred"); 230 //assert(cfg.password == ""); 231 assert(cfg.digest == MongoClientSettings.makeDigest("fred", "")); 232 assert(cfg.database == "baz"); 233 assert(cfg.hosts.length == 1); 234 assert(cfg.hosts[0].name == "localhost"); 235 assert(cfg.hosts[0].port == 27017); 236 237 cfg = MongoClientSettings.init; 238 assert(parseMongoDBUrl(cfg, "mongodb://host1,host2,host3/?safe=true&w=2&wtimeoutMS=2000&slaveOk=true&ssl=true&sslverifycertificate=false")); 239 assert(cfg.username == ""); 240 //assert(cfg.password == ""); 241 assert(cfg.digest == ""); 242 assert(cfg.database == ""); 243 assert(cfg.hosts.length == 3); 244 assert(cfg.hosts[0].name == "host1"); 245 assert(cfg.hosts[0].port == 27017); 246 assert(cfg.hosts[1].name == "host2"); 247 assert(cfg.hosts[1].port == 27017); 248 assert(cfg.hosts[2].name == "host3"); 249 assert(cfg.hosts[2].port == 27017); 250 assert(cfg.safe == true); 251 assert(cfg.w == Bson(2L)); 252 assert(cfg.wTimeoutMS == 2000); 253 assert(cfg.defQueryFlags == QueryFlags.SlaveOk); 254 assert(cfg.ssl == true); 255 assert(cfg.sslverifycertificate == false); 256 257 cfg = MongoClientSettings.init; 258 assert(parseMongoDBUrl(cfg, 259 "mongodb://fred:flinstone@host1.example.com,host2.other.example.com:27108,host3:" 260 ~ "27019/mydb?journal=true;fsync=true;connectTimeoutms=1500;sockettimeoutMs=1000;w=majority")); 261 assert(cfg.username == "fred"); 262 //assert(cfg.password == "flinstone"); 263 assert(cfg.digest == MongoClientSettings.makeDigest("fred", "flinstone")); 264 assert(cfg.database == "mydb"); 265 assert(cfg.hosts.length == 3); 266 assert(cfg.hosts[0].name == "host1.example.com"); 267 assert(cfg.hosts[0].port == 27017); 268 assert(cfg.hosts[1].name == "host2.other.example.com"); 269 assert(cfg.hosts[1].port == 27108); 270 assert(cfg.hosts[2].name == "host3"); 271 assert(cfg.hosts[2].port == 27019); 272 assert(cfg.fsync == true); 273 assert(cfg.journal == true); 274 assert(cfg.connectTimeoutMS == 1500); 275 assert(cfg.socketTimeoutMS == 1000); 276 assert(cfg.w == Bson("majority")); 277 assert(cfg.safe == true); 278 279 // Invalid URLs - these should fail to parse 280 cfg = MongoClientSettings.init; 281 assert(! (parseMongoDBUrl(cfg, "localhost:27018"))); 282 assert(! (parseMongoDBUrl(cfg, "http://blah"))); 283 assert(! (parseMongoDBUrl(cfg, "mongodb://@localhost"))); 284 assert(! (parseMongoDBUrl(cfg, "mongodb://:thepass@localhost"))); 285 assert(! (parseMongoDBUrl(cfg, "mongodb://:badport/"))); 286 287 assert(parseMongoDBUrl(cfg, "mongodb://me:sl$ash/w0+rd@localhost")); 288 assert(cfg.digest == MongoClientSettings.makeDigest("me", "sl$ash/w0+rd")); 289 assert(cfg.hosts.length == 1); 290 assert(cfg.hosts[0].name == "localhost"); 291 assert(cfg.hosts[0].port == 27017); 292 assert(parseMongoDBUrl(cfg, "mongodb://me:sl$ash/w0+rd@localhost/mydb")); 293 assert(cfg.digest == MongoClientSettings.makeDigest("me", "sl$ash/w0+rd")); 294 assert(cfg.database == "mydb"); 295 assert(cfg.hosts.length == 1); 296 assert(cfg.hosts[0].name == "localhost"); 297 assert(cfg.hosts[0].port == 27017); 298 } 299 300 enum MongoAuthMechanism 301 { 302 none, 303 scramSHA1, 304 mongoDBCR, 305 mongoDBX509 306 } 307 308 private MongoAuthMechanism parseAuthMechanism(string str) 309 @safe { 310 switch (str) { 311 case "SCRAM-SHA-1": return MongoAuthMechanism.scramSHA1; 312 case "MONGODB-CR": return MongoAuthMechanism.mongoDBCR; 313 case "MONGODB-X509": return MongoAuthMechanism.mongoDBX509; 314 default: throw new Exception("Auth mechanism \"" ~ str ~ "\" not supported"); 315 } 316 } 317 318 class MongoClientSettings 319 { 320 enum ushort defaultPort = 27017; 321 322 string username; 323 string digest; 324 MongoHost[] hosts; 325 string database; 326 QueryFlags defQueryFlags = QueryFlags.None; 327 string replicaSet; 328 bool safe; 329 Bson w; // Either a number or the string 'majority' 330 long wTimeoutMS; 331 bool fsync; 332 bool journal; 333 long connectTimeoutMS; 334 long socketTimeoutMS; 335 bool ssl; 336 bool sslverifycertificate = true; 337 string sslPEMKeyFile; 338 string sslCAFile; 339 MongoAuthMechanism authMechanism; 340 341 static string makeDigest(string username, string password) 342 @safe { 343 return md5Of(username ~ ":mongo:" ~ password).toHexString().idup.toLower(); 344 } 345 } 346 347 struct MongoHost 348 { 349 string name; 350 ushort port; 351 }