02a7cdc65be7345099fce13bd6ed9f3266628439
[moodle.git] / mnet / xmlrpc / client.php
1 <?php
2 /**
3  * An XML-RPC client
4  *
5  * @author  Donal McMullan  donal@catalyst.net.nz
6  * @version 0.0.1
7  * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
8  * @package mnet
9  */
11 require_once $CFG->dirroot.'/mnet/lib.php';
13 /**
14  * Class representing an XMLRPC request against a remote machine
15  */
16 class mnet_xmlrpc_client {
18     var $method   = '';
19     var $params   = array();
20     var $timeout  = 60;
21     var $error    = array();
22     var $response = '';
23     var $mnet     = null;
25     /**
26      * Constructor returns true
27      */
28     function mnet_xmlrpc_client() {
29         // make sure we've got this set up before we try and do anything else
30         $this->mnet = get_mnet_environment();
31         return true;
32     }
34     /**
35      * Allow users to override the default timeout
36      * @param   int $timeout    Request timeout in seconds
37      * $return  bool            True if param is an integer or integer string
38      */
39     function set_timeout($timeout) {
40         if (!is_integer($timeout)) {
41             if (is_numeric($timeout)) {
42                 $this->timeout = (integer)$timeout;
43                 return true;
44             }
45             return false;
46         }
47         $this->timeout = $timeout;
48         return true;
49     }
51     /**
52      * Set the path to the method or function we want to execute on the remote
53      * machine. Examples:
54      * mod/scorm/functionname
55      * auth/mnet/methodname
56      * In the case of auth and enrolment plugins, an object will be created and
57      * the method on that object will be called
58      */
59     function set_method($xmlrpcpath) {
60         if (is_string($xmlrpcpath)) {
61             $this->method = $xmlrpcpath;
62             $this->params = array();
63             return true;
64         }
65         $this->method = '';
66         $this->params = array();
67         return false;
68     }
70     /**
71      * Add a parameter to the array of parameters.
72      *
73      * @param  string  $argument    A transport ID, as defined in lib.php
74      * @param  string  $type        The argument type, can be one of:
75      *                              none
76      *                              empty
77      *                              base64
78      *                              boolean
79      *                              datetime
80      *                              double
81      *                              int
82      *                              string
83      *                              array
84      *                              struct
85      *                              In its weakly-typed wisdom, PHP will (currently)
86      *                              ignore everything except datetime and base64
87      * @return bool                 True on success
88      */
89     function add_param($argument, $type = 'string') {
91         $allowed_types = array('none',
92                                'empty',
93                                'base64',
94                                'boolean',
95                                'datetime',
96                                'double',
97                                'int',
98                                'i4',
99                                'string',
100                                'array',
101                                'struct');
102         if (!in_array($type, $allowed_types)) {
103             return false;
104         }
106         if ($type != 'datetime' && $type != 'base64') {
107             $this->params[] = $argument;
108             return true;
109         }
111         // Note weirdness - The type of $argument gets changed to an object with
112         // value and type properties.
113         // bool xmlrpc_set_type ( string &value, string type )
114         xmlrpc_set_type($argument, $type);
115         $this->params[] = $argument;
116         return true;
117     }
119     /**
120      * Send the request to the server - decode and return the response
121      *
122      * @param  object   $mnet_peer      A mnet_peer object with details of the
123      *                                  remote host we're connecting to
124      * @return mixed                    A PHP variable, as returned by the
125      *                                  remote function
126      */
127     function send($mnet_peer) {
128         global $CFG, $DB;
131         if (!$this->permission_to_call($mnet_peer)) {
132             mnet_debug("tried and wasn't allowed to call a method on $mnet_peer->wwwroot");
133             return false;
134         }
136         $this->requesttext = xmlrpc_encode_request($this->method, $this->params, array("encoding" => "utf-8", "escaping" => "markup"));
137         $this->signedrequest = mnet_sign_message($this->requesttext);
138         $this->encryptedrequest = mnet_encrypt_message($this->signedrequest, $mnet_peer->public_key);
140         $httprequest = $this->prepare_http_request($mnet_peer);
141         curl_setopt($httprequest, CURLOPT_POSTFIELDS, $this->encryptedrequest);
143         $timestamp_send    = time();
144         mnet_debug("about to send the curl request");
145         $this->rawresponse = curl_exec($httprequest);
146         mnet_debug("managed to complete a curl request");
147         $timestamp_receive = time();
149         if ($this->rawresponse === false) {
150             $this->error[] = curl_errno($httprequest) .':'. curl_error($httprequest);
151             return false;
152         }
153         curl_close($httprequest);
155         $this->rawresponse = trim($this->rawresponse);
157         $mnet_peer->touch();
159         $crypt_parser = new mnet_encxml_parser();
160         $crypt_parser->parse($this->rawresponse);
162         // If we couldn't parse the message, or it doesn't seem to have encrypted contents,
163         // give the most specific error msg available & return
164         if (!$crypt_parser->payload_encrypted) {
165             if (! empty($crypt_parser->remoteerror)) {
166                 $this->error[] = '4: remote server error: ' . $crypt_parser->remoteerror;
167             } else if (! empty($crypt_parser->error)) {
168                 $crypt_parser_error = $crypt_parser->error[0];
170                 $message = '3:XML Parse error in payload: '.$crypt_parser_error['string']."\n";
171                 if (array_key_exists('lineno', $crypt_parser_error)) {
172                     $message .= 'At line number: '.$crypt_parser_error['lineno']."\n";
173                 }
174                 if (array_key_exists('line', $crypt_parser_error)) {
175                     $message .= 'Which reads: '.$crypt_parser_error['line']."\n";
176                 }
177                 $this->error[] = $message;
178             } else {
179                 $this->error[] = '1:Payload not encrypted ';
180             }
182             $crypt_parser->free_resource();
183             return false;
184         }
186         $key  = array_pop($crypt_parser->cipher);
187         $data = array_pop($crypt_parser->cipher);
189         $crypt_parser->free_resource();
191         // Initialize payload var
192         $decryptedenvelope = '';
194         //                                          &$decryptedenvelope
195         $isOpen = openssl_open(base64_decode($data), $decryptedenvelope, base64_decode($key), $this->mnet->get_private_key());
197         if (!$isOpen) {
198             // Decryption failed... let's try our archived keys
199             $openssl_history = get_config('mnet', 'openssl_history');
200             if(empty($openssl_history)) {
201                 $openssl_history = array();
202                 set_config('openssl_history', serialize($openssl_history), 'mnet');
203             } else {
204                 $openssl_history = unserialize($openssl_history);
205             }
206             foreach($openssl_history as $keyset) {
207                 $keyresource = openssl_pkey_get_private($keyset['keypair_PEM']);
208                 $isOpen      = openssl_open(base64_decode($data), $decryptedenvelope, base64_decode($key), $keyresource);
209                 if ($isOpen) {
210                     // It's an older code, sir, but it checks out
211                     break;
212                 }
213             }
214         }
216         if (!$isOpen) {
217             trigger_error("None of our keys could open the payload from host {$mnet_peer->wwwroot} with id {$mnet_peer->id}.");
218             $this->error[] = '3:No key match';
219             return false;
220         }
222         if (strpos(substr($decryptedenvelope, 0, 100), '<signedMessage>')) {
223             $sig_parser = new mnet_encxml_parser();
224             $sig_parser->parse($decryptedenvelope);
225         } else {
226             $this->error[] = '2:Payload not signed: ' . $decryptedenvelope;
227             return false;
228         }
230         // Margin of error is the time it took the request to complete.
231         $margin_of_error  = $timestamp_receive - $timestamp_send;
233         // Guess the time gap between sending the request and the remote machine
234         // executing the time() function. Marginally better than nothing.
235         $hysteresis       = ($margin_of_error) / 2;
237         $remote_timestamp = $sig_parser->remote_timestamp - $hysteresis;
238         $time_offset      = $remote_timestamp - $timestamp_send;
239         if ($time_offset > 0) {
240             $threshold = get_config('mnet', 'drift_threshold');
241             if(empty($threshold)) {
242                 // We decided 15 seconds was a pretty good arbitrary threshold
243                 // for time-drift between servers, but you can customize this in
244                 // the config_plugins table. It's not advised though.
245                 set_config('drift_threshold', 15, 'mnet');
246                 $threshold = 15;
247             }
248             if ($time_offset > $threshold) {
249                 $this->error[] = '6:Time gap with '.$mnet_peer->name.' ('.$time_offset.' seconds) is greater than the permitted maximum of '.$threshold.' seconds';
250                 return false;
251             }
252         }
254         $this->xmlrpcresponse = base64_decode($sig_parser->data_object);
255         $this->response       = xmlrpc_decode($this->xmlrpcresponse);
257         // xmlrpc errors are pushed onto the $this->error stack
258         if (is_array($this->response) && array_key_exists('faultCode', $this->response)) {
259             // The faultCode 7025 means we tried to connect with an old SSL key
260             // The faultString is the new key - let's save it and try again
261             // The re_key attribute stops us from getting into a loop
262             if($this->response['faultCode'] == 7025 && empty($mnet_peer->re_key)) {
263                 mnet_debug('recieved an old-key fault, so trying to get the new key and update our records');
264                 // If the new certificate doesn't come thru clean_param() unmolested, error out
265                 if($this->response['faultString'] != clean_param($this->response['faultString'], PARAM_PEM)) {
266                     $this->error[] = $this->response['faultCode'] . " : " . $this->response['faultString'];
267                 }
268                 $record                     = new stdClass();
269                 $record->id                 = $mnet_peer->id;
270                 $record->public_key         = $this->response['faultString'];
271                 $details                    = openssl_x509_parse($record->public_key);
272                 if(!isset($details['validTo_time_t'])) {
273                     $this->error[] = $this->response['faultCode'] . " : " . $this->response['faultString'];
274                 }
275                 $record->public_key_expires = $details['validTo_time_t'];
276                 $DB->update_record('mnet_host', $record);
278                 // Create a new peer object populated with the new info & try re-sending the request
279                 $rekeyed_mnet_peer = new mnet_peer();
280                 $rekeyed_mnet_peer->set_id($record->id);
281                 $rekeyed_mnet_peer->re_key = true;
282                 return $this->send($rekeyed_mnet_peer);
283             }
284             if (!empty($CFG->mnet_rpcdebug)) {
285                 if (get_string_manager()->string_exists('error'.$this->response['faultCode'], 'mnet')) {
286                     $guidance = get_string('error'.$this->response['faultCode'], 'mnet');
287                 } else {
288                     $guidance = '';
289                 }
290             } else {
291                 $guidance = '';
292             }
293             $this->error[] = $this->response['faultCode'] . " : " . $this->response['faultString'] ."\n".$guidance;
294         }
296         // ok, it's signed, but is it signed with the right certificate ?
297         // do this *after* we check for an out of date key
298         if (!openssl_verify($this->xmlrpcresponse, base64_decode($sig_parser->signature), $mnet_peer->public_key)) {
299             $this->error[] = 'Invalid signature';
300         }
302         return empty($this->error);
303     }
305     /**
306      * Check that we are permitted to call method on specified peer
307      *
308      * @param object $mnet_peer A mnet_peer object with details of the remote host we're connecting to
309      * @return bool True if we permit calls to method on specified peer, False otherwise.
310      */
312     function permission_to_call($mnet_peer) {
313         global $DB, $CFG, $USER;
315         // Executing any system method is permitted.
316         $system_methods = array('system/listMethods', 'system/methodSignature', 'system/methodHelp', 'system/listServices');
317         if (in_array($this->method, $system_methods) ) {
318             return true;
319         }
321         $hostids = array($mnet_peer->id);
322         if (!empty($CFG->mnet_all_hosts_id)) {
323             $hostids[] = $CFG->mnet_all_hosts_id;
324         }
325         // At this point, we don't care if the remote host implements the
326         // method we're trying to call. We just want to know that:
327         // 1. The method belongs to some service, as far as OUR host knows
328         // 2. We are allowed to subscribe to that service on this mnet_peer
330         list($hostidsql, $hostidparams) = $DB->get_in_or_equal($hostids);
332         $sql = "SELECT r.id
333                   FROM {mnet_remote_rpc} r
334             INNER JOIN {mnet_remote_service2rpc} s2r ON s2r.rpcid = r.id
335             INNER JOIN {mnet_host2service} h2s ON h2s.serviceid = s2r.serviceid
336                  WHERE r.xmlrpcpath = ?
337                        AND h2s.subscribe = ?
338                        AND h2s.hostid $hostidsql";
340         $params = array($this->method, 1);
341         $params = array_merge($params, $hostidparams);
343         if ($DB->record_exists_sql($sql, $params)) {
344             return true;
345         }
347         $this->error[] = '7:User with ID '. $USER->id .
348                          ' attempted to call unauthorised method '.
349                          $this->method.' on host '.
350                          $mnet_peer->wwwroot;
351         return false;
352     }
354     /**
355      * Generate a curl handle and prepare it for sending to an mnet host
356      *
357      * @param object $mnet_peer A mnet_peer object with details of the remote host the request will be sent to
358      * @return cURL handle - the almost-ready-to-send http request
359      */
360     function prepare_http_request ($mnet_peer) {
361         $this->uri = $mnet_peer->wwwroot . $mnet_peer->application->xmlrpc_server_url;
363         // Initialize request the target URL
364         $httprequest = curl_init($this->uri);
365         curl_setopt($httprequest, CURLOPT_TIMEOUT, $this->timeout);
366         curl_setopt($httprequest, CURLOPT_RETURNTRANSFER, true);
367         curl_setopt($httprequest, CURLOPT_POST, true);
368         curl_setopt($httprequest, CURLOPT_USERAGENT, 'Moodle');
369         curl_setopt($httprequest, CURLOPT_HTTPHEADER, array("Content-Type: text/xml charset=UTF-8"));
370         curl_setopt($httprequest, CURLOPT_SSL_VERIFYPEER, false);
371         curl_setopt($httprequest, CURLOPT_SSL_VERIFYHOST, 0);
372         return $httprequest;
373     }