#!/usr/bin/php -Cq
<?php
/**
 * EGroupware - tcp-map for Postfix and Active Directory
 *
 * Using multivalued proxyAddresses attribute as implemented in Api\Mail\Smtp\Ads:
 * - "smtp:<email>" allows to receive mail for given <email>
 *   (includes aliases AND primary email)
 * - "forward:<email>" forwards received mail to given <email>
 *   (requires account to have at an "smtp:<email>" value!)
 * - ("forwardOnly" is used for no local mailbox, only forwards, not implemented!)
 * - ("quota:<quota>" is used to store quota)
 *
 * Groups can be used as distribution lists by assigning them an
 * email address via there mail attribute (no proxyAddress)
 *
 * PROTOCOL DESCRIPTION
 * The TCP map class implements a very simple  protocol:  the
 * client  sends  a  request, and the server sends one reply.
 * Requests and replies are sent as one line of  ASCII  text,
 * terminated  by  the  ASCII  newline character. Request and
 * reply parameters (see below) are separated by  whitespace.
 *
 * REQUEST FORMAT
 * Each request specifies a command, a lookup key, and possi-
 * bly a lookup result.
 *
 * get SPACE key NEWLINE
 *     Look up data under the specified key.
 *
 * put SPACE key SPACE value NEWLINE
 *     This request is currently not implemented.
 *
 * REPLY FORMAT
 * Each  reply specifies a status code and text. Replies must
 * be no longer than 4096 characters  including  the  newline
 * terminator.
 *
 * 500 SPACE text NEWLINE
 *     In  case  of  a  lookup request, the requested data
 *     does not exist.  In case of an update request,  the
 *     request  was  rejected.   The  text  describes  the
 *     nature of the problem.
 *
 * 400 SPACE text NEWLINE
 *     This  indicates  an  error  condition.   The   text
 *     describes  the  nature  of  the problem. The client
 *     should retry the request later.
 *
 * 200 SPACE text NEWLINE
 *     The request was successful. In the case of a lookup
 *     request,  the  text  contains an encoded version of
 *     the requested data.
 *
 * ENCODING
 * In request and reply parameters,  the  character  %,  each
 * non-printing character, and each whitespace character must
 * be replaced by %XX, where XX is  the  corresponding  ASCII
 * hexadecimal  character value. The hexadecimal codes can be
 * specified in any case (upper, lower, mixed).
 *
 * The Postfix client always encodes a request.   The  server
 * may  omit  the encoding as long as the reply is guaranteed
 * to not contain the % or NEWLINE character.
 *
 * @author rb(at)stylite.de
 * @copyright (c) 2012-13 by rb(at)stylite.de
 * @package emailadmin
 * @link http://www.postfix.org/tcp_table.5.html
 * @version $Id$
 */

// protect from being called via HTTP
if (php_sapi_name() !== 'cli') die('This is a command line only script!');

// our defaults
$default_host = 'localhost';
$verbose = false;

// allow only clients matching that preg to access, should be only mserver IP
//$only_client = '/^10\.40\.8\.210:/';

// uncomment to write to log-file, otherwise errors go to stderr
//$log = 'syslog';	// or not set (stderr) or filename '/var/log/postfix_tcp_map.log';
//$log_verbose = true;	// error's are always logged, set to true to log failures and success too

// ldap server settings
$ldap_uri = 'ldaps://10.7.102.13/';
$base = 'CN=Users,DC=gruene,DC=intern';
//$bind_dn = "CN=Administrator,$base";
//$bind_dn = "Administrator@gruene.intern";
//$bind_pw = 'secret';
$version = 3;
$use_tls = false;
// supported maps
$maps = array(
	// virtual mailbox map
	'mailboxes' => array(
		'base' => $base,
		'filter' => '(&(objectCategory=person)(proxyAddresses=smtp:%s))',
		'attrs' => 'samaccountname',	// result-attrs must be lowercase!
		'port' => 2001,
	),
	// virtual alias maps
	'aliases' => array(
		'base' => $base,
		'filter' => '(&(objectCategory=person)(proxyAddresses=smtp:%s))',
		'attrs' => array('samaccountname','{forward:}proxyaddresses'),
		'port' => 2002,
	),
	// groups as distribution list
	'groups' => array(
		'base' => $base,
		'filter' => '(&(objectCategory=group)(mail=%s))',
		'attrs' => 'dn',
		// continue with resulting dn
		'filter1' => '(&(objectCategory=person)(proxyAddresses=smtp:*)(memberOf=%s))',
		'attrs1' => array('samaccountname','{forward:}proxyaddresses'),
		'port' => 2003,
	),
);

ini_set('display_errors',false);
error_reporting(E_ALL & ~E_NOTICE);
if ($log) ini_set('error_log',$log);

function usage($extra=null)
{
	global $maps;
	fwrite(STDERR, "\nUsage: $cmd [-v|--verbose] [-h|--help] [-l|--log (syslog|path)] [-q|--query <email-addresse> (mailboxes|alias|groups)] [host]\n\n");
	fwrite(STDERR, print_r($maps,true)."\n");
	if ($extra) fwrite(STDERR, "\n\n$extra\n\n");
	exit(2);
}

$cmd = basename(array_shift($_SERVER['argv']));

while (($arg = array_shift($_SERVER['argv'])) && $arg[0] == '-')
{
	switch($arg)
	{
		case '-v': case '--verbose':
			$verbose = $log_verbose = true;
			break;

		case '-h': case '--help':
			usage();
			break;

		case '-l': case '--log':
			$log = array_shift($_SERVER['argv']);
			break;

		case '-q': case '--query':
			if (count($_SERVER['argv']) == 2)       // need 2 arguments
			{
				$request = 'get '.array_shift($_SERVER['argv'])."\n";
				$map = array_shift($_SERVER['argv']);
				echo respond($request, $map)."\n";
				exit;
			}
			usage();
			break;

		default:
			usage("Unknown option '$arg'!");
	}
}
if ($_SERVER['argv']) usage();

if ($arg)
{
	$host = $arg;
}
else
{
	$host = $default_host;
}

if ($verbose) echo "using $host\n";

$servers = $clients = $buffers = array();

// Create the server socket
foreach($maps as $map => $data)
{
	$addr = 'tcp://'.$host.':'.$data['port'];
	if (!($server = stream_socket_server($addr, $errno, $errstr)))
	{
		fwrite(STDERR, date('Y-m-d H:i:s').": Error calling stream_socket_server('$addr')!\n");
		fwrite(STDERR, $errstr." ($errno)\n");
		exit($errno);
	}
	$servers[$data['port']] = $server;
	$clients[$data['port']] = array();
}
while (true)	// mail loop of tcp server --> never exits
{
	$read = $servers;
	if ($clients) $read = array_merge($read, call_user_func_array('array_merge', array_values($clients)));
	if ($verbose) print 'about to call socket_select(array('.implode(',',$read).', ...) waiting... ';
	if (stream_select($read, $write=null, $except=null, null))	// null = block forever
	{
		foreach($read as $sock)
		{
			if (($port = array_search($sock, $servers)) !== false)
			{
				$client = stream_socket_accept($sock,$timeout,$client_addr);	// @ required to get not timeout warning!

				if ($verbose) echo "accepted connection $client from $client_addr on port $port\n";

				if ($only_client && !preg_match($only_client,$client_addr))
				{
					fwrite($client,"Go away!\r\n");
					fclose($client);
					error_log(date('Y-m-d H:i:s').": Connection $client from wrong client $client_addr (does NOT match '$only_client') --> terminated");
					continue;
				}
				$clients[$port][] = $client;
			}
			elseif (feof($sock))	// client connection closed
			{
				if ($verbose) echo "client $sock closed connection\n";

				foreach($clients as $port => &$socks)
				{
					if (($key = array_search($sock, $socks, true)) !== false)
					{
						unset($socks[$key]);
					}
				}
			}
			else	// client send something
			{
				$buffer =& $buffers[$sock];

				$buffer .= fread($sock, 8096);

				if (strpos($buffer, "\n") !== false)
				{
					list($request, $buffer) = explode("\n", $buffer, 2);

					foreach($maps as $map => $data)
					{
						if (($key = array_search($sock, $clients[$data['port']], true)) !== false)
						{
							if ($verbose) echo date('Y-m-d H:i:s').": client send: $request for map $map\n";

							// Respond to client
							fwrite($sock,  respond($request, $map));
							break;
						}
					}
				}
			}
		}
		if ($except)
		{
			echo "Exception: "; print_r($except);
		}
	}
	else
	{
		// timeout expired
	}
}

/**
 * escapes a string for use in searchfilters meant for ldap_search.
 *
 * Escaped Characters are: '*', '(', ')', ' ', '\', NUL
 * It's actually a PHP-Bug, that we have to escape space.
 * For all other Characters, refer to RFC2254.
 *
 * @param string|array $string either a string to be escaped, or an array of values to be escaped
 * @return string
 */
function quote($string)
{
	return str_replace(array('\\','*','(',')','\0',' '),array('\\\\','\*','\(','\)','\\0','\20'),$string);
}

function respond($request, $map, $extra='', $reconnect=false)
{
	static $ds;
	global $ldap_uri, $version, $use_tls, $bind_dn, $bind_pw;
	global $maps, $log_verbose;

	if (($map == 'aliases' || $map == 'groups') && strpos($request,'@') === false && !$extra)
	{
		return "500 No domain aliases yet\n";
	}
	if (!isset($ds) || $reconnect)
	{
		$ds = ldap_connect($ldap_uri);
		if ($version) ldap_set_option($ds, LDAP_OPT_PROTOCOL_VERSION, $version);
		if ($use_tls) ldap_start_tls($ds);

		if (!@ldap_bind($ds, $bind_dn, $bind_pw))
		{
			error_log("$map: Can't connect to LDAP server $ldap_uri!");
			$ds = null;
			return "400 Can't connect to LDAP server $ldap_uri!\n";	// 400 (temp.) error
		}
	}
	if (!preg_match('/^get ([^\n]+)\n?$/', $request, $matches))
	{
		error_log("$map: Wrong format '$request'!");
		return "400 Wrong format '$request'!\n";	// 400 (temp.) error
	}
	$username = $matches[1];

	list($name,$domain) = explode('@',$username);

	/* check if we are responsible for the given domain
	if ($domain && $map != 'domains' && (int)($response = respond("get $domain", 'domains')) != 200)
	{
		return $response;
	}*/
	$replace = array(
		'%n' => quote($name),
		'%d' => quote($domain),
		'%s' => quote($username),
	);
	$base = strtr($maps[$map]['base'], $replace);
	$filter = strtr($maps[$map]['filter'.$extra], $replace);
	$prefix = isset($maps[$map]['prefix'.$extra]) ? str_replace(array('%n','%d','%s'),array($name,$domain,$username),$maps[$map]['prefix']) : '';
	$search_attrs = $attrs = (array)$maps[$map]['attrs'.$extra];
	// remove prefix like "{smtp:}proxyaddresses"
	foreach($search_attrs as &$attr)
	{
		if ($attr[0] == '{') list(,$attr) = explode('}', $attr);
	}
	unset($attr);

	if (!($sr = @ldap_search($ds, $base, $filter, $search_attrs)))
	{
		$errno = ldap_errno($ds);
		$error = ldap_error($ds).' ('.$errno.')';

		if ($errno == -1)		// eg. -1 lost connection to ldap
		{
			// as DC closes connections quickly, first try to reconnect once, before returning a temp. failure
			if (!$reconnect) return respond($request, $map, $extra, true);

			error_log("$map: get '$username' --> 400 $error: !ldap_search(\$ds, '$base', '$filter')");
			ldap_close($ds);
			$ds = null;	// force new connection on next lookup
			return "400 $error\n";	// 400 (temp.) error
		}
		else	// happens if base containing domain does not exist
		{
			if ($log_verbose) error_log("$map: get '$username' --> 500 Not found: $error: !ldap_search(\$ds, '$base', '$filter')");
			return "500 Not found: $error\n";	// 500 not found
		}
	}
	$entries = ldap_get_entries($ds, $sr);

	if (!$entries['count'])
	{
		if ($log_verbose) error_log("$map: get '$username' --> 500 not found ldap_search(\$ds, '$base', '$filter') no entries");
		return "500 Not found\n";	// 500: Query returned no result
	}
	$response = array();
	foreach($entries as $key => $entry)
	{
		if ($key === 'count') continue;

		foreach($attrs as $attr)
		{
			unset($filter_prefix);
			if ($attr[0] == '{')
			{
				list($filter_prefix, $attr) = explode('}', substr($attr, 1));
			}
			foreach((array)$entry[$attr] as $k => $mail)
			{
				if ($k !== 'count' && ($mail = trim($mail)))
				{
					if ($filter_prefix)
					{
						if (stripos($mail, $filter_prefix) === 0)
						{
							$mail = substr($mail, strlen($filter_prefix));
						}
						else
						{
							continue;
						}
					}
					$response[] = isset($maps[$map]['return']) ? $maps[$map]['return'] : $prefix.$mail;
				}
			}
		}
	}
	if (!$response)
	{
		if ($log_verbose) error_log("$map: get '$username' --> 500 not found ldap_search(\$ds, '$base', '$filter') no response");
		return "500 Not found\n";	// 500: Query returned no result
	}
	if (isset($maps[$map]['filter'.(1+$extra)]) && isset($maps[$map]['attrs'.(1+$extra)]))
	{
		return respond('get '.$response[0], $map, 1+$extra);
	}
	$response = '200 '.implode(',',$response)."\n";
	if ($log_verbose) error_log("$map: get '$username' --> $response");
	return $response;
}