<?php
/**
* Jaxl (Jabber XMPP Library)
*
* Copyright (c) 2009-2012, Abhinav Singh <hide@address.com>.
* All rights reserved.
*
* Redistribution and use in source and binary forms, with or without
* modification, are permitted provided that the following conditions
* are met:
*
* * Redistributions of source code must retain the above copyright
* notice, this list of conditions and the following disclaimer.
*
* * Redistributions in binary form must reproduce the above copyright
* notice, this list of conditions and the following disclaimer in
* the documentation and/or other materials provided with the
* distribution.
*
* * Neither the name of Abhinav Singh nor the names of his
* contributors may be used to endorse or promote products derived
* from this software without specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
* "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
* LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS
* FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE
* COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
* LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
* CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRIC
* LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN
* ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*
*/
date_default_timezone_set("UTC");
declare(ticks = 1);
define('JAXL_CWD', dirname(__FILE__));
require_once JAXL_CWD.'/core/jaxl_exception.php';
require_once JAXL_CWD.'/core/jaxl_cli.php';
require_once JAXL_CWD.'/core/jaxl_loop.php';
require_once JAXL_CWD.'/xmpp/xmpp_stream.php';
require_once JAXL_CWD.'/core/jaxl_event.php';
require_once JAXL_CWD.'/core/jaxl_logger.php';
require_once JAXL_CWD.'/core/jaxl_socket_server.php';
/**
*
* @author abhinavsingh
*/
class RosterItem {
public $jid = null;
public $subscription = null;
public $groups = array();
public $resources = array();
public $vcard = null;
public function __construct($jid, $subscription, $groups) {
$this->jid = $jid;
$this->subscription = $subscription;
$this->groups = $groups;
}
}
/**
* Jaxl class extends base XMPPStream class with following functionalities:
* 1) Adds an event based wrapper over xmpp stream lifecycle
* 2) Provides restart strategy and signal handling to ensure connectivity of xmpp stream
* 3) Roster management as specified in XMPP-IM
* 4) Management of XEP's inside xmpp stream lifecycle
* 5) Adds a logging facility
* 6) Adds a cron job facility in sync with connected xmpp stream timeline
*
* @author abhinavsingh
*
*/
class JAXL extends XMPPStream {
// lib meta info
const version = '3.0.0-alpha-1';
const name = 'JAXL :: Jabber XMPP Library';
// cached init config array
public $cfg = array();
// event callback engine for xmpp stream lifecycle
protected $ev = null;
// reference to various xep instance objects
public $xeps = array();
// local cache of roster list
public $roster = array();
// whether jaxl must also populate local roster cache with
// received presence information about the contacts
public $manage_roster = true;
// what to do with presence sub requests
// "none" | "accept" | "accept_and_add"
public $subscription = "none";
// path variables
public $log_level = JAXL_INFO;
public $priv_dir;
public $tmp_dir;
public $log_dir;
public $pid_dir;
public $sock_dir;
// ipc utils
private $sock;
private $cli;
// env
public $local_ip;
public $pid;
public $mode;
// current status message
public $status;
// identity
public $features = array();
public $category = 'client';
public $type = 'bot';
public $lang = 'en';
// after cth failed attempt
// retry connect after k * $retry_interval seconds
// where k is a random number between 0 and 2^c - 1.
public $retry = true;
private $retry_interval = 1;
private $retry_attempt = 0;
private $retry_max_interval = 32; // 2^5 seconds (means 5 max tries)
public function __construct($config) {
// env
$this->cfg = $config;
$this->mode = PHP_SAPI;
$this->local_ip = gethostbyname(php_uname('n'));
$this->pid = getmypid();
// initialize core modules
$this->ev = new JAXLEvent();
// jid object
$jid = @$this->cfg['jid'] ? new XMPPJid($this->cfg['jid']) : null;
// handle signals
if(extension_loaded('pcntl')) {
pcntl_signal(SIGHUP, array($this, 'signal_handler'));
pcntl_signal(SIGINT, array($this, 'signal_handler'));
pcntl_signal(SIGTERM, array($this, 'signal_handler'));
}
// create .jaxl directory in JAXL_CWD
// for our /tmp, /run and /log folders
// overwrite these using jaxl config array
$this->priv_dir = @$this->cfg['priv_dir'] ? $this->cfg['priv_dir'] : JAXL_CWD."/.jaxl";
$this->tmp_dir = $this->priv_dir."/tmp";
$this->pid_dir = $this->priv_dir."/run";
$this->log_dir = $this->priv_dir."/log";
$this->sock_dir = $this->priv_dir."/sock";
if(!is_dir($this->priv_dir)) mkdir($this->priv_dir);
if(!is_dir($this->tmp_dir)) mkdir($this->tmp_dir);
if(!is_dir($this->pid_dir)) mkdir($this->pid_dir);
if(!is_dir($this->log_dir)) mkdir($this->log_dir);
if(!is_dir($this->sock_dir)) mkdir($this->sock_dir);
// setup logger
if(isset($this->cfg['log_path'])) JAXLLogger::$path = $this->cfg['log_path'];
//else JAXLLogger::$path = $this->log_dir."/jaxl.log";
if(isset($this->cfg['log_level'])) JAXLLogger::$level = $this->log_level = $this->cfg['log_level'];
else JAXLLogger::$level = $this->log_level;
// touch pid file
if($this->mode == "cli") {
touch($this->get_pid_file_path());
_info("created pid file ".$this->get_pid_file_path());
}
// include mandatory xmpp xeps
// service discovery and entity caps
// are recommended for every xmpp entity
$this->require_xep(array('0030', '0115'));
// do dns lookup, update $cfg['host'] and $cfg['port'] if not already specified
$host = @$this->cfg['host']; $port = @$this->cfg['port'];
if(!$host && !$port && $jid) {
// this dns lookup is blocking
_info("dns srv lookup for ".$jid->domain);
list($host, $port) = JAXLUtil::get_dns_srv($jid->domain);
}
$this->cfg['host'] = $host; $this->cfg['port'] = $port;
// choose appropriate transport
// if 'bosh_url' cfg is defined include 0206
if(@$this->cfg['bosh_url']) {
_debug("including bosh xep");
$this->require_xep('0206');
$transport = $this->xeps['0206'];
}
else {
list($host, $port) = JAXLUtil::get_dns_srv($jid->domain);
$stream_context = @$this->cfg['stream_context'];
$transport = new JAXLSocketClient($stream_context);
}
// initialize xmpp stream with configured transport
parent::__construct(
$transport,
$jid,
@$this->cfg['pass'],
@$this->cfg['resource'] ? 'jaxl#'.$this->cfg['resource'] : 'jaxl#'.md5(time()),
@$this->cfg['force_tls']
);
}
public function __destruct() {
// delete pid file
_info("cleaning up pid and unix sock files");
@unlink($this->get_pid_file_path());
@unlink($this->get_sock_file_path());
parent::__destruct();
}
public function get_pid_file_path() {
return $this->pid_dir."/jaxl_".$this->pid.".pid";
}
public function get_sock_file_path() {
return $this->sock_dir."/jaxl_".$this->pid.".sock";
}
public function require_xep($xeps) {
if(!is_array($xeps))
$xeps = array($xeps);
foreach($xeps as $xep) {
$filename = 'xep_'.$xep.'.php';
$classname = 'XEP_'.$xep;
// include xep
require_once JAXL_CWD.'/xep/'.$filename;
$this->xeps[$xep] = new $classname($this);
// add necessary requested callback on events
foreach($this->xeps[$xep]->init() as $ev=>$cb) {
$this->add_cb($ev, array($this->xeps[$xep], $cb));
}
}
}
public function add_cb($ev, $cb, $pri=1) {
return $this->ev->add($ev, $cb, $pri);
}
public function del_cb($ref) {
$this->ev->del($ref);
}
public function set_status($status, $show, $priority) {
$this->send($this->get_pres_pkt(
array(),
$status,
$show,
$priority
));
}
public function send_chat_msg($to, $body, $thread=null, $subject=null) {
$msg = new XMPPMsg(
array(
'type'=>'chat',
'to'=>$to,
'from'=>$this->full_jid->to_string()
),
$body,
$thread,
$subject
);
$this->send($msg);
}
public function get_vcard($jid=null, $cb=null) {
$attrs = array(
'type'=>'get',
'from'=>$this->full_jid->to_string()
);
if($jid) {
$jid = new XMPPJid($jid);
$attrs['to'] = $jid->node."@".$jid->domain;
}
$pkt = $this->get_iq_pkt(
$attrs,
new JAXLXml('vCard', 'vcard-temp')
);
if($cb) $this->add_cb('on_stanza_id_'.$pkt->id, $cb);
$this->send($pkt);
}
public function get_roster($cb=null) {
$pkt = $this->get_iq_pkt(
array(
'type'=>'get',
'from'=>$this->full_jid->to_string()
),
new JAXLXml('query', 'jabber:iq:roster')
);
if($cb) $this->add_cb('on_stanza_id_'.$pkt->id, $cb);
$this->send($pkt);
}
public function start($opts=array()) {
// is bosh bot?
if(@$this->cfg['bosh_url']) {
$this->trans->session_start();
for(;;) {
// while any of the curl request is pending
// keep receiving response
while(sizeof($this->trans->chs) != 0) {
$this->trans->recv();
}
// if no request in queue, ping bosh end point
// and repeat recv
$this->trans->ping();
}
$this->trans->session_end();
return;
}
// is xmpp client or component?
// if on_connect event have no callbacks
// set default on_connect callback to $this->start_stream()
// i.e. xmpp client mode
if(!$this->ev->exists('on_connect'))
$this->add_cb('on_connect', array($this, 'start_stream'));
// connect to the destination host/port
if($this->connect(($this->cfg['port'] == 5223 ? "ssl" : "tcp")."://".$this->cfg['host'].":".$this->cfg['port'])) {
// emit
$this->ev->emit('on_connect');
// parse opts
if(@$opts['--with-debug-shell']) $this->enable_debug_shell();
if(@$opts['--with-unix-sock']) $this->enable_unix_sock();
// run main loop
JAXLLoop::run();
// emit
$this->ev->emit('on_disconnect');
}
// if connection to the destination fails
else {
if($this->trans->errno == 61
|| $this->trans->errno == 110
|| $this->trans->errno == 111
) {
$retry_after = pow(2, $this->retry_attempt) * $this->retry_interval;
$this->retry_attempt++;
_debug("unable to connect with errno ".$this->trans->errno." (".$this->trans->errstr."), will try again in ".$retry_after." seconds");
// TODO: use sigalrm instead
// they usually doesn't gel well inside select loop
sleep($retry_after);
$this->start();
}
else {
$this->ev->emit('on_connect_error', array(
$this->trans->errno,
$this->trans->errstr
));
}
}
}
//
// callback methods
//
// signals callback handler
// not for public api consumption
public function signal_handler($sig) {
$this->end_stream();
$this->disconnect();
$this->ev->emit('on_disconnect');
switch($sig) {
// terminal line hangup
case SIGHUP:
_debug("got sighup");
break;
// interrupt program
case SIGINT:
_debug("got sigint");
break;
// software termination signal
case SIGTERM:
_debug("got sigterm");
break;
}
exit;
}
// called internally for ipc
// not for public consumption
public function on_unix_sock_accept($_c, $addr) {
$this->sock->read($_c);
}
// this currently simply evals the incoming raw string
// know what you are doing while in production
public function on_unix_sock_request($_c, $_raw) {
_debug("evaling raw string rcvd over unix sock: ".$_raw);
$this->sock->send($_c, serialize(eval($_raw)));
$this->sock->read($_c);
}
public function enable_unix_sock() {
$this->sock = new JAXLSocketServer(
'unix://'.$this->get_sock_file_path(),
array(&$this, 'on_unix_sock_accept'),
array(&$this, 'on_unix_sock_request')
);
}
// this simply eval the incoming raw data
// inside current jaxl environment
// security is all upto you, no checks made here
public function handle_debug_shell($_raw) {
print_r(eval($_raw));
echo PHP_EOL;
JAXLCli::prompt();
}
protected function enable_debug_shell() {
$this->cli = new JAXLCli(array(&$this, 'handle_debug_shell'));
JAXLCli::prompt();
}
//
// abstract method implementation
//
protected function send_fb_challenge_response($challenge) {
$this->send($this->get_fb_challenge_response_pkt($challenge));
}
// refer https://developers.facebook.com/docs/chat/#jabber
public function get_fb_challenge_response_pkt($challenge) {
$stanza = new JAXLXml('response', NS_SASL);
$challenge = base64_decode($challenge);
$challenge = urldecode($challenge);
parse_str($challenge, $challenge_arr);
$response = http_build_query(array(
'method' => $challenge_arr['method'],
'nonce' => $challenge_arr['nonce'],
'access_token' => $this->cfg['fb_access_token'],
'api_key' => $this->cfg['fb_app_key'],
'call_id' => 0,
'v' => '1.0'
));
$stanza->t(base64_encode($response));
return $stanza;
}
public function wait_for_fb_sasl_response($event, $args) {
switch($event) {
case "stanza_cb":
$stanza = $args[0];
if($stanza->name == 'challenge' && $stanza->ns == NS_SASL) {
$challenge = $stanza->text;
$this->send_fb_challenge_response($challenge);
return "wait_for_sasl_response";
}
else {
_debug("got unhandled sasl response, should never happen here");
exit;
}
break;
default:
_debug("not catched $event, should never happen here");
exit;
break;
}
}
// someday this needs to go inside xmpp stream
public function wait_for_cram_md5_response($event, $args) {
switch($event) {
case "stanza_cb":
$stanza = $args[0];
if($stanza->name == 'challenge' && $stanza->ns == NS_SASL) {
$challenge = base64_decode($stanza->text);
$resp = new JAXLXml('response', NS_SASL);
$resp->t(base64_encode($this->jid->to_string().' '.hash_hmac('md5', $challenge, $this->pass)));
$this->send($resp);
return "wait_for_sasl_response";
}
else {
_debug("got unhandled sasl response, should never happen here");
exit;
}
break;
default:
_debug("not catched $event, should never happen here");
exit;
break;
}
}
// http://tools.ietf.org/html/rfc5802#section-5
public function get_scram_sha1_response($pass, $challenge) {
// it contains users iteration count i and the user salt
// also server will append it's own nonce to the one we specified
$decoded = $this->explode_data(base64_decode($challenge));
// r=,s=,i=
$nonce = $decoded['r'];
$salt = base64_decode($decoded['s']);
$iteration = intval($decoded['i']);
// SaltedPassword := Hi(Normalize(password), salt, i)
$salted = JAXLUtil::pbkdf2($this->pass, $salt, $iteration);
// ClientKey := HMAC(SaltedPassword, "Client Key")
$client_key = hash_hmac('sha1', $salted, "Client Key", true);
// StoredKey := H(ClientKey)
$stored_key = hash('sha1', $client_key, true);
// AuthMessage := client-first-message-bare + "," + server-first-message + "," + client-final-message-without-proof
$auth_message = '';
// ClientSignature := HMAC(StoredKey, AuthMessage)
$signature = hash_hmac('sha1', $stored_key, $auth_message, true);
// ClientProof := ClientKey XOR ClientSignature
$client_proof = $client_key ^ $signature;
$proof = 'c=biws,r='.$nonce.',p='.base64_encode($client_proof);
return base64_encode($proof);
}
public function wait_for_scram_sha1_response($event, $args) {
switch($event) {
case "stanza_cb":
$stanza = $args[0];
if($stanza->name == 'challenge' && $stanza->ns == NS_SASL) {
$challenge = $stanza->text;
$resp = new JAXLXml('response', NS_SASL);
$resp->t($this->get_scram_sha1_response($this->pass, $challenge));
$this->send($resp);
return "wait_for_sasl_response";
}
else {
_debug("got unhandled sasl response, should never happen here");
exit;
}
break;
default:
_debug("not catched $event, should never happen here");
exit;
break;
}
}
public function handle_auth_mechs($stanza, $mechanisms) {
if($this->ev->exists('on_stream_features')) {
return $this->ev->emit('on_stream_features', array($stanza));
}
$mechs = array();
if($mechanisms) foreach($mechanisms->childrens as $mechanism) $mechs[$mechanism->text] = true;
$pref_auth = @$this->cfg['auth_type'] ? $this->cfg['auth_type'] : 'PLAIN';
$pref_auth_exists = isset($mechs[$pref_auth]) ? true : false;
_debug("pref_auth ".$pref_auth." ".($pref_auth_exists ? "exists" : "doesn't exists"));
if($pref_auth_exists) {
// try prefered auth
$mech = $pref_auth;
}
else {
// choose one from available mechanisms
foreach($mechs as $mech=>$any) {
if($mech == 'X-FACEBOOK-PLATFORM') {
if(@$this->cfg['fb_access_token']) {
break;
}
}
else {
break;
}
}
_error("preferred auth type not supported, trying $mech");
}
$this->send_auth_pkt($mech, @$this->jid ? $this->jid->to_string() : null, @$this->pass);
if($pref_auth == 'X-FACEBOOK-PLATFORM') {
return "wait_for_fb_sasl_response";
}
else if($pref_auth == 'CRAM-MD5') {
return "wait_for_cram_md5_response";
}
else if($pref_auth == 'SCRAM-SHA-1') {
return "wait_for_scram_sha1_response";
}
}
public function handle_auth_success() {
// if not a component
/*if(!@$this->xeps['0114']) {
$this->xeps['0030']->get_info($this->full_jid->domain, array(&$this, 'handle_domain_info'));
$this->xeps['0030']->get_items($this->full_jid->domain, array(&$this, 'handle_domain_items'));
}*/
$this->ev->emit('on_auth_success');
}
public function handle_auth_failure($reason) {
$this->ev->emit('on_auth_failure', array(
$reason
));
}
public function handle_stream_start($stanza) {
$stanza = new XMPPStanza($stanza);
$this->ev->emit('on_stream_start', array($stanza));
return array(@$this->cfg['bosh_url'] ? 'wait_for_stream_features' : 'connected', 1);
}
public function handle_iq($stanza) {
$stanza = new XMPPStanza($stanza);
// emit callback registered on stanza id's
$emited = false;
if($stanza->id && $this->ev->exists('on_stanza_id_'.$stanza->id)) {
//_debug("on stanza id callbackd");
$emited = true;
$this->ev->emit('on_stanza_id_'.$stanza->id, array($stanza));
}
// catch roster list
if($stanza->type == 'result' && ($query = $stanza->exists('query', 'jabber:iq:roster'))) {
foreach($query->childrens as $child) {
if($child->name == 'item') {
$jid = $child->attrs['jid'];
$subscription = $child->attrs['subscription'];
$groups = array();
foreach($child->childrens as $group) {
if($group->name == 'group') {
$groups[] = $group->name;
}
}
$this->roster[$jid] = new RosterItem($jid, $subscription, $groups);
}
}
// emit this event if not emited above
if(!$emited)
$this->ev->emit('on_roster_update');
}
// if managing roster
// catch contact vcard results
if($this->manage_roster && $stanza->type == 'result' && ($query = $stanza->exists('vCard', 'vcard-temp'))) {
if(@$this->roster[$stanza->from])
$this->roster[$stanza->from]->vcard = $query;
}
// on_get_iq, on_result_iq, and other events are only
// emitted if on_stanza_id_{id} wasn't emitted above
if(!$emited)
$this->ev->emit('on_'.$stanza->type.'_iq', array($stanza));
}
public function handle_presence($stanza) {
$stanza = new XMPPStanza($stanza);
// if managing roster
// catch available/unavailable type stanza
if($this->manage_roster) {
$type = ($stanza->type ? $stanza->type : "available");
$jid = new XMPPJid($stanza->from);
if($type == 'available') {
$this->roster[$jid->bare]->resources[$jid->resource] = $stanza;
}
else if($type == 'unavailable') {
if(@$this->roster[$jid->bare] && @$this->roster[$jid->bare]->resources[$jid->resource])
unset($this->roster[$jid->bare]->resources[$jid->resource]);
}
}
$this->ev->emit('on_presence_stanza', array($stanza));
}
public function handle_message($stanza) {
$stanza = new XMPPStanza($stanza);
$this->ev->emit('on_'.$stanza->type.'_message', array($stanza));
}
// unhandled event and arguments bubbled up
// TODO: in a lot of cases this will be called, need more checks
public function handle_other($event, $args) {
$stanza = $args[0];
$stanza = new XMPPStanza($stanza);
$ev = 'on_'.$stanza->name.'_stanza';
if($this->ev->exists($ev)) {
return $this->ev->emit($ev, array($stanza));
}
else {
_warning("event '".$event."' catched in handle_other with stanza name ".$stanza->name);
}
}
public function handle_domain_info($stanza) {
$query = $stanza->exists('query', NS_DISCO_INFO);
foreach($query->childrens as $k=>$child) {
if($child->name == 'identity') {
//echo 'identity category:'.@$child->attrs['category'].', type:'.@$child->attrs['type'].', name:'.@$child->attrs['name'].PHP_EOL;
}
else if($child->name == 'x') {
//echo 'x ns:'.$child->ns.PHP_EOL;
}
else if($child->name == 'feature') {
//echo 'feature var:'.$child->attrs['var'].PHP_EOL;
}
}
}
public function handle_domain_items($stanza) {
$query = $stanza->exists('query', NS_DISCO_ITEMS);
foreach($query->childrens as $k=>$child) {
if($child->name == 'item') {
//echo 'item jid:'.@$child->attrs['jid'].', name:'.@$child->attrs['name'].', node:'.@$child->attrs['node'].PHP_EOL;
}
}
}
}
?>