Server Diagram
To keep things simple, each database operation returns an instance of DBResult. Conceptually, class DBResult needs to return a set of hits - those records in the database matching the query request, a success or failure flag and an error string in the event that a failure has occurred. To simplify the process, three type definitions are made:
typedef SHARED_PTR<ExtendedPersonRecord> spRecord; typedef DBHit spRecord; typedef std::list<DBHit> DBHitList;
Class DBResult is derived from MynahSA::IoBase to allow results from database operations to be archived, and the class definition is:
class DBResult : public MynahSA::IoBase { public: enum Result { Undefined, Ok, Error }; DBResult(); DBResult(Result res, const std::string& what, const DBHitList& hits); DBResult(const DBResult& result); ~DBResult(); std::string ioName() const; void serialize(MynahSA::Archive& ar); const DBHitList& getHits() const; const std::string& what() const; bool isOk() const { return _result == Ok; } private: Result _result; std::string _whatString; DBHitList _hits; };
The use of class DBResult is fairly clear: When a result is received the isOk() method is used to determine if an error occurred. In the event that something went wrong the error message is displayed, otherwise, the list of successful hits are displayed.
The next step is to define class Database. Because Database is shared amongst all threads the a MynahSA::Mutex is stored in the private section:
#include <boost/thread.hpp> #include <boost/utility.hpp> #include <boost/thread/condition.hpp> #include <boost/thread/thread.hpp> class Database : public MynahSA::IoBase { public: ... DBResult queryName(const std::string& nameStart, const std::string& nameEnd) const; DBResult queryAge(int ageStart, int ageEnd) const; DBResult queryEyeColor(PersonRecord::EyeColor ec) const; DBResult queryAll() const; DBResult modifyRecord(unsigned int uid, spRecord newRecord); DBResult deleteRecord(unsigned int uid); DBResult insertRecord(spRecord newRecord); ... private: mutable MynahSA::Mutex _mutex; };
The implementation of class Database uses a simple macro LOCK which uses a scope sensitive lock (MynahSA::Lock) to making the user methods atomic:
#define LOCK Lock __l(_mutex)
DBResult Database::queryName(const string& nameStart, const string& nameEnd) const { LOCK; DBHitList hl; string nameEndQuery; if (nameEnd == "") { nameEndQuery = nameStart; } else { nameEndQuery = nameEnd; } basicQuery(nameStart, nameEndQuery, _nameMap, hl); return DBResult(DBResult::Ok, "", hl); }
The implementation of each user method starts with the macro LOCK. The locking of each user method is essential: Race conditions could easily put the private data members of class Database out of sync and cause serious problems.
The above implementation takes two string parameters. The two strings are used to denote a range of names. In the event that the second string nameEnd is empty, the behavior of the queryName function is to return only those names matching nameStart. Finally, the implementation of queryName depends on template helper function basicQuery. basicQuery selects records within the range of its first two parameters from the multimap supplied in the third parameter and copies the matching hits onto the hitlist, the final parameter.
class DatabaseDriver { public: DatabaseDriver(); virtual ~DatabaseDriver(); virtual DBResult queryName(const std::string& nameStart, const std::string& nameEnd) const = 0; virtual DBResult queryAge(int ageStart, int ageEnd) const = 0; virtual DBResult queryEyeColor(PersonRecord::EyeColor ec) const = 0; virtual DBResult queryAll() const = 0; virtual DBResult modifyRecord(unsigned int uid, const std::string& name, int age, PersonRecord::EyeColor ec) = 0; virtual DBResult deleteRecord(unsigned int uid)=0; virtual DBResult insertRecord(const std::string& name, int age, PersonRecord::EyeColor ec) = 0; };
The Parser class performs one of two things when a string is fed to it:
class Parser { public: Parser(DatabaseDriver& dbd); DBResult operator()(const std::string& s); private: DatabaseDriver& _driver; };
When operator() is invoked, the string presented is parsed and actions on the database driver are called.
To test the functionality of the database prior to implementing a client/server system a trivial wrapper class DatabaseFileDriver is derived from DatabaseDriver containing an instance of Database. The implementation is trivial and can be used to provide a direct console to database interface. The code shown below is from examples/tutorial4/directdbtest.cpp and was used to test the implementation of class Database prior to developing the client/server interface:
// a direct database test: // access and use a database from a file. // read commands from stdin, process them on the database. // report errors if they occur. // write the database to disk on exit #include <iostream> #include <string> #include "databasefiledriver.hpp" #include "parser.hpp" using namespace std; int main() { DatabaseFileDriver myDriver("testdb.dat"); Parser myParser(myDriver); string cmd; // write the prompt cout << "> "; cout.flush(); // while data is available, loop: while (getline(cin, cmd)) { try { if (!cmd.empty()) { // parse command, write result DBResult res = myParser(cmd); cout << res << endl; } } catch (logic_error& le) { cerr << le.what() << endl; } cout << "> "; cout.flush(); } }
The code above provides an overview of how the networked client will operate: A DatabaseDriver implementation is created and used by an instance of class Parser that handles command input. The mechanism by which the DatabaseDriver communicates with the underlying database is implementation specific, and lends itself nicely to hiding a network interface.
The client to server path is, however, more complex and two alternative design routes can be taken:
The following diagram shows the protocol:
Server Protocol Diagram
The implementation of class DBRequest will use the following header:
class DBRequest : public MynahSA::IoBase { public: enum RequestType { UNKNOWN, QUERY_NAME, QUERY_AGE, QUERY_EYE_COLOR, QUERY_ALL, MODIFY_RECORD, DELETE_RECORD, INSERT_RECORD }; RequestType _request; std::string _name; std::string _name2; int _age; int _age2; unsigned int _uid; PersonRecord::EyeColor _eyeColor; DBRequest(RequestType request); std::string ioName() const; void serialize(MynahSA::Archive& ar); };
This class is treated like a C struct, and the data members are left available for public access. The serialize method is responsible (like all other serialize methods) for placing each of the data members onto an Archive instance.
Using class DBRequest a database driver can be created:
class DatabaseSSLRPCDriver : public DatabaseDriver { public: DatabaseSSLRPCDriver(MynahSA::SSLRPCClient& client); ~DatabaseSSLRPCDriver(); DBResult queryName(const std::string& nameStart, const std::string& nameEnd) const; DBResult queryAge(int ageStart, int ageEnd) const; DBResult queryEyeColor(PersonRecord::EyeColor) const; DBResult queryAll() const; DBResult modifyRecord(unsigned int uid, const std::string& name, int age, PersonRecord::EyeColor ec); DBResult deleteRecord(unsigned int uid); DBResult insertRecord(const std::string& name, int age, PersonRecord::EyeColor ec); private: // Perform RPC and convert result from an IoBase to a DBResult, // or throw a logic error DBResult rpcIt(const SHARED_PTR<MynahSA::IoBase>& req) const; mutable MynahSA::SSLRPCClient& _client; };
The DatabaseSSLRPCDriver constructor takes a previously connected SSLRPCClient object and uses it to communicate with the server. The following code segment shows the implementation of the queryName and rpcIt method. All other database methods follow the same structure. Refer to examples/tutorial4/databasesslrpcdriver.cpp for further details.
DBResult DatabaseSSLRPCDriver::rpcIt(const SHARED_PTR<IoBase>& req) const { SHARED_PTR<IoBase> ptr(_client.rpc(req)); if (ptr->ioName() == "DBResult") { return *static_pointer_cast<DBResult>(ptr); } else { throw logic_error("DatabaseSSLRPCDriver::rpcIt - " "failed to convert IoBase pointer to DBResult pointer"); } } DBResult DatabaseSSLRPCDriver::queryName(const string& nameStart, const string& nameEnd) const { SHARED_PTR<DBRequest> dbr(new DBRequest(DBRequest::QUERY_NAME)); dbr->_name = nameStart; dbr->_name2 = nameEnd; return rpcIt(dbr); }
class DBRequestResponseHandler : public MynahSA::RequestResponseHandler { public: DBRequestResponseHandler(Database& db); ~DBRequestResponseHandler(); SHARED_PTR<MynahSA::IoBase> operator()(const SHARED_PTR<MynahSA::IoBase>& req); private: Database& _db; };
Each time an incoming connection is received by MynahSA::SSLServer a separate thread is spawned and given access to the RequestResponseHandler instance. Because each instance holds a reference to the database, each thread can perform the required database calls. The fundamental difference between a stateless server (as in Tutorial 3: TCP Client/Server Demonstration) and a server with state is in the state of the RequestResponseHandler implementation. The implementation of DBRequestResponseHandler is straight-forward: Incoming requests (as class MynahSA::IoBase) are converted into DBRequest classes, the actions are performed and the resulting DBResult is returned to from the server. The implementation from examples/tutorial4/dbrequestresponsehandler.cpp is shown below:
SHARED_PTR<MynahSA::IoBase> DBRequestResponseHandler::operator()(const SHARED_PTR<IoBase>& req) { if (req->ioName() == DBRequest().ioName()) { SHARED_PTR<DBRequest> dbreq = static_pointer_cast<DBRequest>(req); DBResult result; switch (dbreq->_request) { case DBRequest::QUERY_NAME: result = _db.queryName( dbreq->_name, dbreq->_name2 ); break; case DBRequest::QUERY_AGE: result = _db.queryAge( dbreq->_age, dbreq->_age2 ); break; case DBRequest::QUERY_EYE_COLOR: result = _db.queryEyeColor( dbreq->_eyeColor ); break; case DBRequest::QUERY_ALL: result = _db.queryAll(); break; case DBRequest::MODIFY_RECORD: result = _db.modifyRecord( dbreq->_uid, spRecord(new ExtendedPersonRecord(dbreq->_name, dbreq->_age, dbreq->_eyeColor) ) ); break; case DBRequest::DELETE_RECORD: result = _db.deleteRecord( dbreq->_uid ); break; case DBRequest::INSERT_RECORD: result = _db.insertRecord( spRecord( new ExtendedPersonRecord(dbreq->_name, dbreq->_age, dbreq->_eyeColor) ) ); break; default: result = DBResult(DBResult::Error, "Unknown request", DBHitList()); }; return SHARED_PTR<IoBase>(new DBResult(result)); } else { throw logic_error("Error, invalid request object"); } }
int main(int argc, char** argv) { saInit(); // initialize WinSock, SSL, etc... try { string certificateFile = ""; string pkeyFile = ""; string machineName =""; int port=0; // parse arguments parseClientArgs(argc, argv, machineName, port, certificateFile, pkeyFile); // create the constructor object. DBRegister myConstructor; cerr << "Performing setup" << endl; // construct ssl rpc client SSLRPCClient myClient(myConstructor, machineName, port, certificateFile, pkeyFile); DatabaseSSLRPCDriver myDriver(myClient); Parser myParser(myDriver); string cmd; cout << "< "; cout.flush(); while (getline(cin, cmd)) { try { if (!cmd.empty()) { DBResult res = myParser(cmd); cout << res << endl; } } catch (logic_error& le) { cerr << le.what() << endl; } cout << "> "; cout.flush(); } } catch(StringCastException& bc) { cerr << "Port number could not be converted into an integer." " Check arguments!" << endl; return 1; } catch (ExceptionBase& eb) { cerr << "Caught a fatal exception: " << eb.what() << endl; return 1; } catch (...) { cerr << "Uncaught exception. Check and debug!" << endl; return 1; } return 0; }
The logic behind the client is simple: Create a constructor registration object (DBRegister), build the SSLRPCClient object, bind a DatabaseSSLRPCDriver to it. Finally, build a parser instance on top of the driver and process commands.
What is different from this implementation and previous examples is the exception handling. In this example, std::logic_error is trapped in the inner loop; this exception is thrown when parse errors are encountered and are considered non-fatal exceptions. There are two potential fatal exceptions which the program may generate: MynahSA::StringCastException may be generated by the command line parser in attempting to convert a string to an integer for the port number, and MynahSA::ExceptionBase exceptions may be generated during the client setup phase and/or during the communications. A MynahSA::ExceptionBase will be generated if the server vanishes while a connection is still established.
// step 0: Initialize the database Database db("testdb.dat"); // step 1: build the ping request response handler DBRequestResponseHandler drh(db); // step 2: create the stream registration object DBRegister myConstructor;
The code path is then split depending on whether or not the user supplied a certificate and private key pair or not. In the event that certificate and private key files were provided the program starts an authenticating server using the following code path:
SSLConnectionCertVerifier myVerifier; // step 4: instantiate a connection manager on the handler and the // stream constructor SSLConnectionManager cm(drh, myConstructor, myVerifier); // step 5: build an ssl server on top of the connection manager instance SSLRPCServer server(cm, certFile, privKeyFile, serverPort,caFile);
The SSLConnectionCertVerifier object is a functor provided to the server that is used to validate or drop connections. The supplied implementation checks that the client's certificate has a canonical name (CN) field and that it matches the network name of the connecting machine. This is intended to prevent a stolen certificate from being used on a different machine.
In the event that no certificate file was provided a default certificate is created using the alternative code path, shown below:
SSLConnectionVerifier myVerifier; // step 4: instantiate a connection manager on the handler and the //stream constructor SSLConnectionManager cm(drh, myConstructor, myVerifier); // step 5: build an ssl server on top of the connection //manager instance mkcert(&cert, &pkey, 1024, 0, 760); // create cert and pkey - completely self signed 1024 bit SSLRPCServer server(cm, cert, pkey, serverPort,caFile);
The SSLConnectionVerifier class is a connection verifier that accepts every connection coming in without examining the client's DNS information.
Note: even though SSLConnectionVerifier does no specific information checking, OpenSSL will not accept an incoming connection if the certificate does not match the certificate authority file - this means that clients placed behind NATted networks that have no DNS name can connect, provided they have a signed certificate.
The call to MynahSA::mkcert is used to create a self signed certificate and associated private key. The 1024 specifies that a 1024 bit RSA cipher will be used, the serial number will be zero and the certificate will be valid for 760 days.
Whether using authentication or not both code paths share the same code for handling incoming connections:
while (running) { try { server.checkClients(5); } catch (const ServerConnectionException& sce) { cerr << "Connection error detected: " << sce.what() << endl; } }
The boolean variable running is used to control termination of the server. A signal handler is installed to manage keyboard interrupts and set running to false. The exception ServerConnectionException is handled locally as it pertains to exceptions raised during the establishment of a TCP connection and occur within the main thread. Once a connection is established, a separate thread with local exception handling is used to manage all network input and output for that thread.
One final part of the server code is the trapping of ServerException exceptions. These exceptions are viewed as fatal and only occur when the master TCP port (to allow incoming connections) is unavailable or there is a serious error with OpenSSL startup. For further details, see examples/tutorial4/ssldbserver.cpp.
The procedure shown below is an interactive session using OpenSSL's tool chain to build the certificate files. The session was conducted on a fictional domain "brettnet.net" with a machine named "warchief." Because the authentication process checks for validity of domain names it is necessary to provide the correct domain name of both the client and server machines in this process.
The first step is to build the root private key and root certificate. The root certificate will serve as the basis for signing all other certificates, thus restricting the clients that can access the server.
[bms20@warchief tmp]$ openssl req -x509 -nodes -days 365 -newkey rsa:1024 \ -keyout rootkey.pem -out rootcert.pem Generating a 1024 bit RSA private key .................................++++++ ......................++++++ writing new private key to 'rootkey.pem' ----- You are about to be asked to enter information that will be incorporated into your certificate request. What you are about to enter is what is called a Distinguished Name or a DN. There are quite a few fields but you can leave some blank For some fields there will be a default value, If you enter '.', the field will be left blank. ----- Country Name (2 letter code) [GB]: State or Province Name (full name) [Berkshire]:Cambridgeshire Locality Name (eg, city) [Newbury]:Cambridge Organization Name (eg, company) [My Company Ltd]:Mynah-Software LTD Organizational Unit Name (eg, section) []:Development Common Name (eg, your name or your server's hostname) []:warchief.brettnet.net Email Address []:brett@mynah-software.com [bms20@warchief tmp]$
[bms20@warchief tmp]$ openssl x509 -in rootcert.pem -text > CAfile.pem
Next, a server certificate request and private key are generated for the server certificate. The entry of the details for the server have been omitted, however, like the creation of the root certificate above, the correct DNS name for the server is required:
[bms20@warchief tmp]$ openssl req -nodes -newkey rsa:1024 \ -keyout serverkey.pem -new \ -days 365 -out serverreq.pem
The server certificate is produced by filling in the certificate request - this certificate is signed by the certificate authority:
[bms20@warchief tmp]$ openssl x509 -days 180 -CA rootcert.pem \ -CAkey rootkey.pem -req -CAcreateserial \ -CAserial serverCA.srl -in serverreq.pem \ -out servercert.pem
openssl req -nodes -newkey rsa:1024 -keyout clientkey.pem -new \ -days 365 -out clientreq.pem
openssl x509 -days 180 -CA rootcert.pem -CAkey rootkey.pem \ -req -CAcreateserial -CAserial clientCA.srl -in clientreq.pem -out clientcert.pem
A helper script, examples/tutorial4/make_certificates.sh is provided to help with the process of creating the necessary certificates.
Finally, to start the server in authenticated mode, issue the command:
./ssldbserver -cert servercert.pem -pkey serverkey.pem -CAfile CAfile.pem
And a sample session using the client could be:
./ssldbclient warchief 7501 -cert clientcert.pem -pkey clientkey.pem Performing setup > queryAll() Hit unique id: 1 Name: Brett Age: 33 Eye Color: Blue Friends: Hit unique id: 2 Name: Joe Age: 29 Eye Color: Brown Friends: Hit unique id: 3 Name: Jim Age: 60 Eye Color: Blue Friends: >