#!/usr/bin/env php
<?php
/*
 * PowerTinyDynDNS :: Tinydyndns-compatible dynamic DNS service for PowerDNS
 *
 * Version 2.0.0, November 16, 2025
 * Copyright (c) 2012-2025, Ron Guerin <ron@vnetworx.net>
 *
 * This script implements a tinydyndns-compatible dynamic DNS service under
 * PowerDynDNS, the PowerDNS and MySQL based dynamic DNS service.
 * It can be hosted on the DNS primary server and use the PowerDNS database
 * directly, or it can be hosted anywhere else, and access the PowerDNS
 * primary server via the PowerDNS Simple API. (not the native PowerDNS API)
 *
 * Requires: PHP_PCRE, PHP_PCNTL, PHP_POSIX
 *
 * Do not edit this script!  Edit /etc/${scriptname}.conf to change settings.
 * where ${scriptname} is the name of this file.  Changes made to the script
 * will get overwritten on upgrades.
 *
 * TinyPowerDynDNS is Free Software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * PowerTinyDynDNS is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
 * See the GNU General Public License for more details.
 *
 * If you are not able to view the file COPYING, please write to the
 * Free Software Foundation, Inc.,
 * 59 Temple Place, Suite 330, Boston, MA  02111-1307  USA
 * to get a copy of the GNU General Public License or to report a
 * possible license violation.
 *
 * @package PowerDynDNS
 * @author Ron Guerin <ron@vnetworx.net>
 * @license http://www.fsf.org/licenses/gpl.html GNU Public License
 * @copyright Copyright &copy; 2012-2025 Ron Guerin
 * @filesource
 * @link http://gothamcode.com/powerdyndns PowerDynDNS
 * @version 2.0.0
 *
 */

/// * Defer as much as possible until end of code that may run as root * ///
//////////////////// Code that follows may run as root /////////////////////
/////////////// Code that follows may run multiple instances ///////////////

define('VERSION', '2.0.0');
error_reporting(E_ALL);
mysqli_report(MYSQLI_REPORT_ERROR);
gc_enable(); // Enable garbage collection
define('MIN_PHP_VERSION', '7.4.0');
define('MEPATH', realpath($argv[0]));
define('VERSIONSTAMP', date('F j, Y H:i:s', filemtime(MEPATH)));
$me = basename(__FILE__);
define('ME', (substr($me, -4) == '.php') ? substr($me, 0, strlen($me) - 4) : $me);
cli_set_process_title(ME); // set proctitle
pcntl_signal(SIGINT, function($signal) { shutdown(TRUE); }); // these are to ensure the shutdown function runs on signals
pcntl_signal(SIGTERM, function($signal) { exit; });
pcntl_async_signals(TRUE); // Process signals immediately
restore_standard_timezone_policy();

if (version_compare(PHP_VERSION, MIN_PHP_VERSION) < 0) { // Is our PHP good enough?
	log_msg('Error: '.ME.' '.VERSION.' needs PHP >= '.MIN_PHP_VERSION.' (you are using '.PHP_VERSION.')', TRUE);
	exit(1);
}

define('ERROR', 1);
define('CANT_OPEN_CONF', 2);
define('ALREADY_RUNNING', 3);
define('CANT_OPEN_DB', 4);
define('NEED_PRIVS', 5);
define('CANT_DROP_PRIVS', 6);
define('CANT_DAEMONIZE', 7);
define('CANT_SETSID', 8);
define('CANT_GET_LOCK', 9);
define('CANT_OPEN_DATABASE', 10);
define('CANT_OPEN_PORT', 11);
define('BAD_CONFIG', 100);
define('BAD_CONFIG_DIRS', 101);
define('OK', '+OK'."\r\n");
define('OK_HELLO', 'PowerTinyDynDNS '.VERSION.' ready'."\r\n");
define('OK_BYE', '+OK PowerTinyDynDNS signing off'."\r\n");
define('OK_UIDL', '+OK'."\r\n.\r\n");
define('OK_CAPA', '+OK'."\r\n".'CAPA'."\r\n".'TOP'."\r\n".'UIDL'."\r\n".'USER'."\r\n".'.'."\r\n");
define('OK_CAPA_STLS', '+OK'."\r\n".'CAPA'."\r\n".'STLS'."\r\n".'TOP'."\r\n".'UIDL'."\r\n".'USER'."\r\n".'.'."\r\n");
define('OK_STLS', '+OK Begin TLS negotiation'."\r\n");
define('OK_LIST', '+OK 0 messages'."\r\n.\r\n");
define('OK_STAT', '+OK 0 0'."\r\n");
define('ERR_AUTH_FIRST', '-ERR authorization first'."\r\n");
define('ERR_AUTH_FAILED', '-ERR authorization failed'."\r\n");
define('ERR_USER_FIRST', '-ERR USER first'."\r\n");
define('ERR_NO_SUCH_MESSAGE', '-ERR no such message'."\r\n");
define('ERR_INVALID_COMMAND', '-ERR Invalid command, try one of: STAT, LIST [msg], RETR msg, '
			.'TOP msg n, DELE msg, UIDL [msg], NOOP, RSET, QUIT'."\r\n");
define('ERR_INVALID_USER', '-ERR Invalid command, try USER [username]'."\r\n");
define('ERR_TIMEOUT', '-ERR (timeout) PowerTinyDynDNS signing off'."\r\n");
define('ERR_FAILS', '-ERR (errors) Too much erroneous input from user'."\r\n");
define('ERR_SHUTDOWN', '-ERR (service down) PowerTinyDynDNS signing off'."\r\n");
define('ERR_TLS_FAILED', '-ERR TLS negotiation failed'."\r\n");

// Parse command-line, early
$error = $found = FALSE;
foreach ($argv as $index => $arg) {
	if (! $index) continue; // skip $argv[0]
	switch ($arg) {
		case '-v':
		case '--version':
			echo VERSION."\n";
			exit;
			break;
		case '?':
		case '-?':
		case '-h':
		case '--help':
		case 'help':
			help();
			exit;
			break;
		default:
			$found = TRUE;
			break;
	}
}
if (! $found) {
	help(TRUE);
	exit;
}

if (version_compare(phpversion(), MIN_PHP_VERSION)<0) {
	$msg = 'Error: '.$argv[0].' '.VERSION.' needs PHP >= '.MIN_PHP_VERSION.' (you are using '.phpversion().')';
	log_msg($msg, TRUE);
	exit(1);
}

// If you run both PowerDynDNS and PowerTinyDynDNS, put settings in: powerdyndns.conf
if (is_readable('/etc/powerdyndns/powerdyndns.conf.php')) $conf = '/etc/powerdyndns/powerdyndns.conf.php';
elseif (is_readable('/etc/powerdyndns/powertinydyndns.conf.php')) $conf = '/etc/powerdyndns/powertinydyndns.conf.php';
elseif (is_readable('/etc/powerdns/powerdyndns.conf.php')) $conf = '/etc/powerdns/powerdyndns.conf.php';
elseif (is_readable('/etc/powerdns/powertinydyndns.conf.php')) $conf = '/etc/powerdns/powertinydyndns.conf.php';
else {
	@fwrite(STDERR, 'Error: Could not find config file.'."\n");
	exit(1);
}
require_once $conf;

define('DEBUG', (isset($debug) && $debug === TRUE) ? TRUE : FALSE);
define('VERBOSE', (isset($debug) && $debug === TRUE) ? TRUE : FALSE);
define('SYSLOG', ((! isset($syslog)) || (isset($syslog) && ($syslog !== FALSE))) ? TRUE : FALSE);
define('LOGFILE', (isset($logfile)) ? $logfile : '/var/log/powerdyndns/powerdyndns-'.date('Y-m').'.log');
define('DAEMONIZE', (isset($daemonize) && $daemonize === TRUE) ? TRUE : FALSE);
define('PIDPATH', (isset($pidpath)) ? $pidpath : '/var/run');
define('LOCKFILE', (isset($lockfile)) ? $lockfile : PIDPATH.'/'.ME.'.lock');
define('RUNASUSER', (isset($runasuser)) ? $runasuser : FALSE);
define('HOSTNAME', (isset($hostname)) ? $hostname : php_uname('n'));
define('DBHOST', (isset($dbhost)) ? $dbhost : '127.0.0.1');
define('DBPORT', (isset($dbport)) ? $dbport : 3306);
define('DBUSER', (isset($dbuser)) ? $dbuser : FALSE);
define('DBPASS', (isset($dbpass)) ? $dbpass : FALSE);
define('DBNAME', (isset($dbname)) ? $dbname : FALSE);
define('UTABLE', (isset($usertable)) ? $usertable : 'dynusers');
define('PTABLE', (isset($policytable)) ? $policytable : 'dynpolicy');
define('SSLKEY', (isset($sslkey)) ? $sslkey : FALSE);
define('SSLCERT', (isset($sslcert)) ? $sslcert : FALSE);
define('INSECURE', (isset($insecure) && $insecure === TRUE) ? TRUE : FALSE);
define('TLS', (isset($tls) && $tls === TRUE) ? TRUE : FALSE);
define('STARTTLS', (isset($starttls) && $starttls === TRUE) ? TRUE : FALSE);
define('ADDRESS', (isset($address)) ? $address : '0.0.0.0');
define('INSECUREPORT', (isset($insecureport)) ? $insecureport : 110); // Standard POP3 and STARTTLS port
define('SECUREPORT', (isset($secureport)) ? $secureport : 995); // Standard POP3S port
define('DYNZONE', (isset($dynzone)) ? $dynzone : FALSE);
define('DYNTTL', (isset($dynttl)) ? $dynttl : 300); // 300 seconds = 5 minutes
define('SOCKTIMEOUT', (isset($socktimeout)) ? $socktimeout : ini_get('default_socket_timeout'));
define('SESSTIMEOUT', (isset($sessiontimeout)) ? $sessiontimeout : 60); // 60 seconds
define('MAXCLIENTS', (isset($maxclients)) ? $maxclients : 10);
define('LOGPW', (isset($logpw) && ($logpw === TRUE)) ? TRUE : FALSE);
define('LOGFAILPW', (isset($logfailpw) && ($logfailpw === TRUE)) ? TRUE : FALSE);
define('BLOCKTIME', (isset($blocktime)) ? $blocktime : 600); // 600 seconds = 10 minutes
define('BLOCKTRIGGER', (isset($blocktrigger)) ? $blocktrigger : 8);
define('DNSACCESS', (isset($dnsaccess)) ? $dnsaccess : 'db'); // db or api
define('APIKEY', (isset($pdnsapikey)) ? $pdnsapikey : FALSE);
define('APIURL', (isset($pdnsapiurl)) ? $pdnsapiurl : FALSE);

if ((DNSACCESS == 'db') && ((! DBNAME) || (! DBUSER) || (! DBPASS))) {
	log_msg('Error: DB name, DB user, and DB password must be defined when DB DNS access is on.', TRUE);
	$error = TRUE;
}
if ((DNSACCESS == 'api') && ((! APIKEY) || (! APIURL))) {
	log_msg('Error: API key and URL must be defined when API DNS access is on.', TRUE);
	$error = TRUE;
}
if ((! INSECURE) && (! STARTTLS) && (! TLS)) {
	log_msg('Error: No Services defined.', TRUE);
	$error = TRUE;
}
if ((! INSECURE) && STARTTLS) {
	log_msg('Error: Cannot use STARTTLS without insecure port.', TRUE);
	$error = TRUE;
}
if ((! INSECUREPORT) && (! SECUREPORT)) {
	log_msg('No ports defined.', TRUE);
	$error = TRUE;
}
if (STARTTLS || TLS) {
	if ((! SSLKEY) || (! SSLCERT)) {
		log_msg('Error: TLS/STARTTLS enabled but sslcert or sslkey not configured.', TRUE);
		$error = TRUE;
	}
	if (! file_exists(SSLCERT)) {
		log_msg('Error: SSL certificate file not found: '.SSLCERT, TRUE);
		$error = TRUE;
	}
	if (! file_exists(SSLKEY)) {
		log_msg('Error: SSL key file not found: '.SSLKEY, TRUE);
		$error = TRUE;
	}
}
if (! DYNZONE) {
	log_msg('Error: No dynamic DNS zone defined.', TRUE);
	$error = TRUE;
}
if ($error) {
	log_msg('Cannot continue, terminating.', TRUE);
	exit(1);
}

// Parse command-line, later
$help = $error = $skip = $found = $daemonize = $start = $stop = $restart
	= $reload = $block = $unblock = $exclusive = $exclusives = FALSE;
$exclusiveslist = '';
foreach ($argv as $index => $arg) {
	if (! $index) continue; // skip $argv[0]
	if ($skip) {
		$skip = FALSE;
		continue;
	}
	$argbase = ($pos = strpos($arg, '=')) ? substr($arg, 0, $pos) : $arg;
	$argval = ($pos = strpos($arg, '=')) ? substr($arg, $pos + 1)
		: ((array_key_exists($index+1, $argv) && (substr($argv[$index+1], 0, 1) != '-')) ? $argv[$index+1] : FALSE);
	switch ($argbase) {
		case '-d':
		case '--daemonize':
			$daemonize = TRUE;
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			break;
		case '-s':
		case '--start':
			$start = TRUE;
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			break;
		case '-S':
		case '--stop':
			$stop = TRUE;
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			break;
		case '-R':
		case '--restart':
			$restart = TRUE;
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			break;
		case '-r':
		case '--reload':
			$reload = TRUE;
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			break;
		case '-b':
		case '--block':
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			if (! $argval) {
				@fwrite(STDERR, 'Error: No filename given for makefile for '.$argbase."\n");
				$error = TRUE;
			}
			$block = $argval;
			$skip = TRUE;
			break;
		case '-u':
		case '--unblock':
			$found = TRUE;
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			if (! $argval) {
				@fwrite(STDERR, 'Error: No filename given for makefile for '.$argbase."\n");
				$error = TRUE;
			}
			$unblock = $argval;
			$skip = TRUE;
			break;
		default:
			@fwrite(STDERR, 'Error: Invalid argument ('.$argv[1].')'."\n");
			exit(INVALID_ARGUMENT);
			break;
	}
}

$error = FALSE;
if (! $found) {
	@fwrite(STDERR, 'Error: Valid argument must be specified.'."\n");
	$help = TRUE;
}
if ($exclusives) {
	@fwrite(STDERR, 'Error: Cannot specify together '.rtrim($exclusiveslist)."\n");
	$help = TRUE;
}
if ($help) {
	help(TRUE);
	$error = TRUE;
}
if ($error) exit(1);

if ($block) { // block IP address
	$dbhandle = open_database();
	block_ip($dbhandle, $block);
	exit;
}
if ($unblock) { // unblock IP address
	$dbhandle = open_database();
	unblock_ip($dbhandle, $unblock);
	exit;
}

if (SYSLOG) openlog(ME, LOG_PID, LOG_USER); // Open syslog

// Daemonize and return lock (or just lock), handle TERM and HUP signals
// Open any database after forking (daemonization) to avoid pain & suffering
if (! ($LOCK = ((DAEMONIZE) ? daemonize() : lock_pid()))) { // daemonize this instance or run in foreground
	log_msg(ME.' already running or unable to obtain lock.', TRUE);
	exit(1);
}
pcntl_signal(SIGINT,  'sig_handler');    // terminate
pcntl_signal(SIGTERM, 'sig_handler');    // terminate
pcntl_signal(SIGUSR1, 'sig_handler');    // restart
pcntl_async_signals(TRUE);               // process signals immediately
register_shutdown_function('shutdown');

//////////////// End of code that may run multiple instances ///////////////

// Check that we have the necessary privileges to open the ports requested
if (((INSECUREPORT < 1024) || (SECUREPORT < 1024)) && posix_getuid() != 0) {
	$userinfo = posix_getpwuid(posix_geteuid());
	$msg = 'Error: Running as unprivileged user ('.$userinfo['name']
		.') while requesting privileged ports. Root privileges required to open port(s) ';
	if (INSECUREPORT < 1024) {
		$msg .= INSECUREPORT;
		if (SECUREPORT < 1024) $msg .= ', ';
	}
	if (SECUREPORT < 1024) $msg .= SECUREPORT;
	$msg .= ' Terminating.';
	log_msg($msg, TRUE);
	closelog();
	exit(1);
}

if (STARTTLS || TLS) {
	// Read certificate and key files NOW while we still have privileges
	// Create a combined PEM file (cert+key) in a temporary location
	$certpair = tempnam(sys_get_temp_dir(), 'ptdd_');
	$pemdata = file_get_contents(SSLCERT)."\n".file_get_contents(SSLKEY);
	$omask = umask(0077); // Set restrictive umask before creating temporary file (owner only)
	if (file_put_contents($certpair, $pemdata) === FALSE) {
		umask($old_umask); // Restore umask before exiting
		log_msg('Error: Unable to create temporary SSL PEM file. Terminating.', TRUE);
		exit(1);
	}
	umask($omask); // Restore original umask

	// If we're going to drop privileges, change ownership now
	if ((posix_getuid() == 0) && RUNASUSER) {
		$userinfo = posix_getpwnam(RUNASUSER);
		chown($certpair, $userinfo['uid']);
		chgrp($certpair, $userinfo['gid']);
	}
}
define('CERTPAIR', (isset($certpair)) ? $certpair : '');

// Now get about the point of all this...
$using = '';
if (INSECURE) $using .= 'port '.INSECUREPORT;
if (STARTTLS) $using .= (($using) ? ' ' : '').'with STARTTLS';
if (TLS) $using .= (($using) ? ', ' : '').'TLS port '.SECUREPORT;
log_msg('PowerTinyDynDNS Version '.VERSION.' listening on '.ADDRESS.' '.$using.', Max clients: '.MAXCLIENTS);

// Open up to two possible ports, unencrypted/starttls, and encrypted.
if (INSECURE && INSECUREPORT) $serversock = open_socket(ADDRESS, INSECUREPORT, 'insecure');
if (SECUREPORT && TLS) $sslserversock = open_socket(ADDRESS, SECUREPORT, 'tls');

// If I am root (0), drop privs now that config has been read and ports and logs are open.
if (posix_getuid() == 0) {
	if (RUNASUSER) {
		$userinfo = posix_getpwnam(RUNASUSER);
		posix_setuid($userinfo['uid']);
		posix_setgid($userinfo['gid']);
	}
	else {
		$msg = 'Error: Unable to drop privileges. Set \'runasuser\' in '.ME.'.conf or start as unprivileged user.';
		log_msg($msg, TRUE);
		closelog();
		exit(1);
	}
}

//////////////////// End of code that may run as root //////////////////////

// Open database
$dbhandle = open_database(FALSE);

$sessions = array(); $clientsockets = array(); $sslclientsockets = array();
do { // Endless loop. Receives TERM signal to shut down.
	if (INSECUREPORT && INSECURE) {
		$othersessions = array();
		if (SECUREPORT) foreach ($sslclientsockets as $socket) $othersessions[] = $socket['client'];
		process_socket($serversock, $clientsockets, $othersessions, $dbhandle, FALSE);
	}
	if (SECUREPORT && TLS) {
		$othersessions = array();
		if (INSECUREPORT) foreach ($clientsockets as $socket) $othersessions[] = $socket['client'];
		process_socket($sslserversock, $sslclientsockets, $othersessions, $dbhandle, TRUE);
	}
} while (TRUE);

// We'll never actually get here since while (TRUE) runs forever
exit;


####################################################################################################################################
####################################################################################################################################


function shutdown($error=FALSE) {
	$count = count($GLOBALS['clientsockets']) + count($GLOBALS['sslclientsockets']);
	shutdown_clients($GLOBALS['clientsockets'], $count);
	$count = count($GLOBALS['clientsockets']) + count($GLOBALS['sslclientsockets']);
	shutdown_clients($GLOBALS['sslclientsockets'], $count);
	if (INSECURE && (is_object($GLOBALS['serversock']))) fclose($GLOBALS['serversock']);
	if (TLS && (is_object($GLOBALS['sslserversock']))) fclose($GLOBALS['sslserversock']);
	if (CERTPAIR && file_exists(CERTPAIR)) unlink(CERTPAIR);
	log_msg('Service terminated.');
	closelog();
	exit((int) $error);
}

function shutdown_clients(&$sessions, $count) {
	foreach ($sessions as $sessionid => $session) {
		// Shut down client connection due to shutdown
		$client = $sessions[$sessionid]['client'];
		$out = ERR_SHUTDOWN;
		fwrite($sessions[$sessionid]['socket'], $out, strlen($out));
		fclose($sessions[$sessionid]['socket']);
		unset($sessions[$sessionid]);
		$msg = 'Session ('.$client.') ended for: '.$sessionid
			.' (shutdown) Sessions: '.$count.'/'.MAXCLIENTS;
		log_msg($msg);
	}
}

function open_socket($address, $port, $mode) {
	$context = stream_context_create();
	if ($mode == 'tls') {
		// TLS-only mode (port 995)
		stream_context_set_option($context, 'ssl', 'local_cert', CERTPAIR);
		stream_context_set_option($context, 'ssl', 'verify_peer', FALSE);
		stream_context_set_option($context, 'ssl', 'verify_peer_name', FALSE);
		stream_context_set_option($context, 'ssl', 'allow_self_signed', TRUE);
		// Use 'tls://' instead of 'ssl://' for better protocol negotiation
		$socket = @stream_socket_server('tls://'.$address.':'.$port,
			$errno, $errstr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN, $context);
		if ($errno) {
			log_msg($errstr, TRUE);
			return FALSE;
		}
	}
	else {
		// Plain text mode (port 110) - STARTTLS will be handled per-connection
		$socket = @stream_socket_server('tcp://'.$address.':'.$port,
			$errno, $errstr, STREAM_SERVER_BIND|STREAM_SERVER_LISTEN);
		if ($errno) {
			log_msg($errstr, TRUE);
			return FALSE;
		}
	}
	if ($socket === FALSE) {
		log_msg('Error: Unable to open socket '.$address.':'.$port.' err: ('.$errno.') '.$errstr, TRUE);
		closelog();
		exit(1);
	}
	return $socket;
}

function process_socket($serversock, &$sessions, $othersessions, &$dbhandle, $tls=FALSE) {
	// Since there is a global limit on clients (sessions), we need to know both how many
	// other sessions are managed by other $serversockets, and what client numbers are in use.
	// $othersessions is an array of client numbers managed by any other $serversockets

	$dberror = FALSE;

	// Timeout any clients on this server over the time limit
	$now = date("U");
	foreach ($sessions as $sessionid => $session) {
		if ($session['lastop'] + SESSTIMEOUT < $now) {
			// Shut down this client connection due to timeout
			$client = $sessions[$sessionid]['client'];
			$out = ERR_TIMEOUT;
			if ($sessions[$sessionid]['socket'] instanceof \Socket)
				fwrite($sessions[$sessionid]['socket'], $out, strlen($out));
			fclose($sessions[$sessionid]['socket']);
			$msg = 'Session ('.$client.') ended for: '.$sessionid.' (timeout) Sessions: '
				.(count($sessions) + count($othersessions)).'/'.MAXCLIENTS;
			log_msg($msg);
			unset($sessions[$sessionid]);
		}
	}

	// Make a blocking call to stream_select(), pass only a copy of the sockets array,
	// because the array returned by stream_select() only contains sockets that have changed.
	$clientsockets = array();
	foreach ($sessions as $sessionid => $session) {
		if (is_resource($session['socket'])) $clientsockets[] = $session['socket'];
		else unset($sessions[$sessionid]);
	}
	$read = array_merge(array($serversock), $clientsockets);

	$null = NULL; // prevents error passing reference if NULL used directly below
	// Call stream_select with error suppression on anyway, otherwise SIGTERM throws a warning
	if (@stream_select($read, $null, $null, $tv_sec=1, $tv_usec=500000) === FALSE) {
		pcntl_signal_dispatch();
		log_msg('Error: Problem with blocking stream_select call.', TRUE);
		return;
	}

   // Handle new connections while not exceeding MAXCLIENTS, reject IPs on a local blocklist
	// $read has been modified by stream_select() and now contains only clients waiting to be read from.
	// If the server socket has been modified, we have a new client connecting.
 	if (in_array($serversock, $read) && ((count($sessions) + count($othersessions)) < MAXCLIENTS)) {
		// Accept the client connection, get the socket and session id
		if (($clientsock = @stream_socket_accept($serversock, SOCKTIMEOUT, $sessionid)) === FALSE) {
			$msg = ($serversock instanceof \Socket) ? ': '.socket_strerror(socket_last_error($serversock)) : '';
			if ($msg) log_msg('Error accepting socket'.$msg, TRUE);
			return;
		}
		$remoteip = substr($sessionid, 0, strrpos($sessionid, ':')); // 127.0.0.1:12345 or [fe80::1]:12345

		// Terminate connections from blocked IPs
		if (blocked_ip($dbhandle, $remoteip)) {
			$out = '-ERR (forbidden)';
			if ($listed) $out .= ' '.$remoteip.' is listed in '.$listed;
			$out .= "\r\n";
			@fwrite($clientsock, $out, strlen($out));
			fclose($clientsock);
			log_msg('blocked: '.$remoteip);
		}
		else {
			$clientsockets[] = $clientsock;
			$sessions[$sessionid]['socket'] = $clientsock;
			$sessions[$sessionid]['user'] = NULL;
			$sessions[$sessionid]['encrypted'] = FALSE; // Track encryption state
			$sessions[$sessionid]['starttlscapable'] = FALSE; // Track if this connection supports STARTTLS

			if ($tls) {
				 // This connection came from the TLS-only port (tddsport) - already encrypted
				 $sessions[$sessionid]['encrypted'] = TRUE;
				 $sessions[$sessionid]['starttlscapable'] = FALSE;
			}
			else {
				 // This is a plain connection on tddport
				 $sessions[$sessionid]['encrypted'] = FALSE;
				 // STARTTLS is capable if it's enabled in config
				 $sessions[$sessionid]['starttlscapable'] = (STARTTLS ? TRUE : FALSE);
			}

			// There's got to be a better way
			// Each $serversocket (cleartext and SSL) is maintained separately, but they share a global client limit,
			// So we need to check both this $serversocket and the other $serversockets's client numbers,
			// which were passed in $othersessions, to find the first available (session) client number.
			$inuse = array();
			foreach ($sessions as $session) if (array_key_exists('client', $session)) $inuse[] = $session['client'];
			for ($i=1; $i<=MAXCLIENTS; $i++) {
				if ((! in_array($i, $inuse)) && (! in_array($i, $othersessions))) {
					// $i is now first unused client number on this server
					$sessions[$sessionid]['client'] = $i;
					break;
				}
			}
			$sessions[$sessionid]['auth'] = NULL;
			$sessions[$sessionid]['badcmd'] = 0;
			$sessions[$sessionid]['lastop'] = date("U");
			$remoteip = substr($sessionid, 0, strrpos($sessionid, ':')); // 127.0.0.1:12345 or [fe80::1]:12345
			$msg = 'Session ('.$sessions[$sessionid]['client'].') started for: '.$remoteip
				.' Sessions: '.(count($sessions) + count($othersessions)).'/'.MAXCLIENTS;
			log_msg($msg);

			$out = '+OK <'.getmypid().'-'.$sessions[$sessionid]['client'].'.'.date('U').'@'.HOSTNAME."> ".OK_HELLO;
			@fwrite($clientsock, $out, strlen($out));
		}
	} // End of accept new connection or not

   // Handle Input
	foreach ($clientsockets as $key => $clientsock) { // for each client
		pcntl_signal_dispatch();
		$closeconnection = FALSE;
		$resid = get_resource_id($clientsock);
		if (FALSE === ($sessionid = stream_socket_get_name($clientsock, TRUE))) {
			$client = '(unknown)';
			foreach ($sessions as $id => $sess) {
				if ($resid == get_resource_id($sess['socket'])) {
					$sessionid = $id;
					$client = $sessions[$sessionid]['client'];
					break;
				}
			}
			fclose($clientsock);
			unset($sessions[$sessionid]);
			$remoteip = substr($sessionid, 0, strrpos($sessionid, ':')); // 127.0.0.1:12345 or [fe80::1]:12345
			$msg = 'Session ('.$client.') ended for: '.$remoteip.' Sessions: '
				.(count($sessions) + count($othersessions)).'/'.MAXCLIENTS;
			log_msg($msg);
			continue;
		}
		if (! in_array($clientsock, $read)) continue;
		if (FALSE === ($buf = fread($clientsock, 2048))) {
			$msg = ($clientsock instanceof \Socket) ? ': '.socket_strerror(socket_last_error($clientsock)) : '';
			log_msg('Error reading socket'.$msg, TRUE);
			continue;
		}
		$buf = trim($buf);
		if (! preg_match('/(.*) *(.*)/', $buf, $matches)) continue;
		// If we're still here (no continue) then we've got data to process
		$commandline = explode(' ', $matches[1]);
		$command = strtoupper($commandline[0]);
		if (isset($commandline[1])) $argument = $commandline[1]; else $argument = '';

		// Process POP3 commands to the extent necessary to simulate a
		// conversation with a POP3 server that never has any mail in its
		// users mailboxes.
		switch($command) {
			case 'STLS':
				// Handle STARTTLS command
				if (! $sessions[$sessionid]['starttlscapable']) {
					$out = ERR_INVALID_COMMAND;
					$sessions[$sessionid]['badcmd']++;
				}
				elseif ($sessions[$sessionid]['encrypted']) {
					$out = '-ERR TLS already active'."\r\n";
					$sessions[$sessionid]['badcmd']++;
				}
				elseif ($sessions[$sessionid]['auth']) {
					$out = '-ERR Cannot initiate TLS after authentication'."\r\n";
					$sessions[$sessionid]['badcmd']++;
				}
				else {
					// Send OK response before starting TLS
					$out = OK_STLS;
					@fwrite($clientsock, $out, strlen($out));

					// Enable crypto on the socket
					stream_context_set_option($clientsock, 'ssl', 'local_cert', CERTPAIR);
					stream_context_set_option($clientsock, 'ssl', 'verify_peer', FALSE);
					stream_context_set_option($clientsock, 'ssl', 'verify_peer_name', FALSE);
					stream_context_set_option($clientsock, 'ssl', 'allow_self_signed', TRUE);

					$result = @stream_socket_enable_crypto($clientsock, TRUE, STREAM_CRYPTO_METHOD_TLS_SERVER);

					if ($result === TRUE) {
						$out = ''; // already sent response
						$sessions[$sessionid]['encrypted'] = TRUE;
						$sessions[$sessionid]['starttlscapable'] = FALSE; // can't do STARTTLS again
						if (DEBUG) log_msg('Session ('.$sessions[$sessionid]['client'].') upgraded to TLS');
					}
					else {
						$out = ERR_TLS_FAILED;
						$closeconnection = TRUE;
						log_msg('Session ('.$sessions[$sessionid]['client'].') TLS negotiation failed');
					}
				}
				break;

			case 'USER':
				if (! $argument) {
					$out = ERR_INVALID_USER;
					$sessions[$sessionid]['badcmd']++;
				}
				elseif ($sessions[$sessionid]['auth']) {
					$out = ERR_INVALID_COMMAND;
					$sessions[$sessionid]['badcmd']++;
				}
				elseif ($status = blocked_user($dbhandle, $argument)) { // Terminate connections from disabled users
					if ($status == 1) $out = '-ERR (permanently disabled, contact support)'."\r\n";
					else $out = '-ERR (temporarily disabled, try later)'."\r\n";
					log_msg('blocked: '.$argument);
					$closeconnection = TRUE;
				}
				else {
					$out = OK;
					$sessions[$sessionid]['user'] = $argument;
					$sessions[$sessionid]['badcmd'] = 0;
				}
				break;
			case 'PASS':
				if (! $sessions[$sessionid]['user']) {
					$out = ERR_USER_FIRST;
					$sessions[$sessionid]['badcmd']++;
				}
				else {
					$pass = $argument;
					$sessions[$sessionid]['badcmd'] = 0;
					// Validate the user
					$remoteip = substr($sessionid, 0, strrpos($sessionid, ':')); // 127.0.0.1:12345 or [fe80::1]:12345
					$return = valid_user_updated($dbhandle, $sessions[$sessionid]['user'], $pass, $remoteip, $hostname);
					if (($return === TRUE) || ($return == 2)) {
						$dberror = FALSE;
						$out = OK;
						$sessions[$sessionid]['auth'] = time();
					}
					else {
						$out = ERR_AUTH_FAILED;
						$dberror = $return;
						$closeconnection = TRUE;
					}
				}
				break;
			case 'QUIT':
				$out = OK_BYE;
				$closeconnection = TRUE;
				break;
			case 'STAT':
				if (! $sessions[$sessionid]['auth']) {
					$out = ERR_AUTH_FIRST;
					$sessions[$sessionid]['badcmd']++;
				}
				else {
					$out = OK_STAT;
					$sessions[$sessionid]['badcmd'] = 0;
				}
				break;
			case 'LIST':
				if (! $sessions[$sessionid]['auth']) {
					$out = ERR_AUTH_FIRST;
					$sessions[$sessionid]['badcmd']++;
				}
				elseif ($argument) $out = ERR_NO_SUCH_MESSAGE;
				else $out = OK_LIST;
				break;
			case 'NOOP':
				$out = OK;
				$sessions[$sessionid]['badcmd'] = 0;
				break;
			case 'CAPA':
				// Return STLS in capabilities if STARTTLS is available and not yet encrypted
				if ($sessions[$sessionid]['starttlscapable'] && (! $sessions[$sessionid]['encrypted'])) $out = OK_CAPA_STLS;
				else $out = OK_CAPA;
				$sessions[$sessionid]['badcmd'] = 0;
				break;
			case 'UIDL':
				if (! $sessions[$sessionid]['auth']) {
					$out = ERR_AUTH_FIRST;
					$sessions[$sessionid]['badcmd']++;
				}
				elseif ($argument) {
					$out = ERR_NO_SUCH_MESSAGE;
					$sessions[$sessionid]['badcmd'] = 0;
				}
				else {
					$out = OK_UIDL;
					$sessions[$sessionid]['badcmd'] = 0;
				}
				break;
			case 'RSET':
				if (! $sessions[$sessionid]['auth']) {
					$out = ERR_AUTH_FIRST;
					$sessions[$sessionid]['badcmd']++;
				}
				else {
					$out = OK;
					$sessions[$sessionid]['badcmd'] = 0;
				}
				break;
			case 'RETR':
			case 'TOP':
			case 'DELE':
				if (! $sessions[$sessionid]['auth']) {
					$out = ERR_AUTH_FIRST;
					$sessions[$sessionid]['badcmd']++;
				}
				else {
					$out = ERR_NO_SUCH_MESSAGE;
					$sessions[$sessionid]['badcmd'] = 0;
				}
				break;
			default:
				$sessions[$sessionid]['badcmd']++;
				if (! $sessions[$sessionid]['auth']) $out = ERR_AUTH_FIRST;
				else $out = ERR_INVALID_COMMAND;
		}
		if ($sessions[$sessionid]['badcmd'] > 4) {
			$out = ERR_FAILS;
			$closeconnection = TRUE;
		}

		$sessions[$sessionid]['lastop'] = date("U");
		if ($out != '') @fwrite($clientsock, $out, strlen($out));

		if ($closeconnection) {
			// Shut down this client connection
			$client = $sessions[$sessionid]['client'];
			fclose($clientsock);
			unset($sessions[$sessionid]);
			$remoteip = substr($sessionid, 0, strrpos($sessionid, ':')); // 127.0.0.1:12345 or [fe80::1]:12345
			$msg = 'Session ('.$client.') ended for: '.$remoteip.' Sessions: '
				.(count($sessions) + count($othersessions)).'/'.MAXCLIENTS;
			log_msg($msg);
		}
	}
	pcntl_signal_dispatch();
	usleep(100000); // sleep 1/10th of a second
	if ($dberror == 2006) { // MySQL server has gone away
		@mysqli_close($dbhandle);
		$dbhandle = open_database();
	}
}

function sig_handler($signo) {
	switch ($signo) {
		case SIGTERM:
		case SIGINT:
			// handle shutdown tasks
			log_msg('Ending operation.');
			exit;
			break;
		case SIGUSR1:
			// handle restart tasks
			restart();
			break;
	}
}

function restart() {
	log_msg('Restarting '.ME.' at '.date('F j, Y H:i:s'));
	$args = array();
	foreach ($GLOBALS['argv'] as $key => $arg) {
		if (! $key) continue;
		$args[] = $arg;
	}
	shutdown();
	pcntl_exec($GLOBALS['argv'][0], $args); // remember $argv[0] will *not* be the SUID wrapper (if used)
	exit(1); // should never get here after a pcntl_exec()
}

function log_msg($msg, $error=FALSE) {
	if (DEBUG) $msg .= ' mem: '.memory_get_usage().' max: '.memory_get_peak_usage();
	if (SYSLOG) syslog((($error) ? LOG_ERR : LOG_INFO), $msg);
	if (LOGFILE) @file_put_contents(LOGFILE, date('Y-m-d H:i:s ').$msg."\n", FILE_APPEND|LOCK_EX);
	if ((! DAEMONIZE) && VERBOSE) {
		if (! $error) echo date('M d H:i:s ').$msg."\n";
		else @fwrite(STDERR, date('M d H:i:s ').$msg."\n");
	}
}

function pdns_api_call($uri, $endpoint, $pdnsapikey, $method, $data, &$response) {
	// Makes a PowerDNS Simple API call via HTTP(S)
	$method = strtoupper($method);
	$response = array();
	if (((! defined('PDNS_API_IS_INSECURE')) || (PDNS_API_IS_INSECURE !== TRUE)) && (substr($uri, 0, 8) != 'https://')) {
		$msg = 'API URI must be HTTPS!';
		error('Error: '.$msg);
		$response = array('code' => 490, 'data' => array('error' => $msg));
		return FALSE;
	}
	if (($endpoint != 'zones') && ($endpoint != 'records') && ($endpoint != 'servers')) {
		$response = array('code' => 404, 'data' => array('error' => 'Endpoint not found'));
		return FALSE;
	}
	if (($endpoint == 'records') || ($endpoint == 'zones')) {
		$uri .= '/'.$endpoint.'/'.$data['zone'];
		unset($data['zone']);
	}
	elseif ($endpoint == 'servers') {
		$uri .= '/'.$endpoint.'/'.$data['server'].'/'.$data['service'];
	}
	if ($method == 'GET') {
		$header  = 'Content-Type: application/json'."\r\n";
		$header .= 'X-Base64-JSON-Body: '.base64_encode(json_encode($data))."\r\n";
		$query = '';
		if (array_key_exists('zoneid', $data)) $query .= (($query) ? '&' : '').'zoneid='.$data['zoneid'];
		if (array_key_exists('name', $data)) $query .= (($query) ? '&' : '').'name='.$data['name'];
		if (array_key_exists('type', $data)) $query .= (($query) ? '&' : '').'type='.$data['type'];
		if (array_key_exists('content', $data)) $query .= (($query) ? '&' : '').'content='.htmlentities($data['content']);
		if (array_key_exists('ttl', $data)) $query .= (($query) ? '&' : '').'ttl='.$data['ttl'];
		if (array_key_exists('prio', $data)) $query .= (($query) ? '&' : '').'prio='.$data['prio'];
		if (array_key_exists('disabled', $data)) $query .= (($query) ? '&' : '').'disabled='.$data['disabled'];
		$uri .= '?'.rawurlencode($query);
	}
	else $header = 'Content-Type: application/json'."\r\n";
	$opts = array('http' => array('protocol_version'=> '1.1', 'method' => $method, 'ignore_errors' => TRUE,
		'timeout' => 60, 'header' => 'Connection: close'."\r\n"
		.'Authorization: Basic '.base64_encode(':'.$pdnsapikey)."\r\n"
		.'Accept: application/json'."\r\n".$header, 'user_agent' => ME.'/'.VERSION, 'follow_location' => TRUE));
	if ($method != 'GET') $opts['http']['content'] = json_encode($data);
	$response['data'] = json_decode(file_get_contents($uri, FALSE, stream_context_create($opts), FALSE, 4000000), TRUE);
	$http = (! empty($http_response_header)) ? $http_response_header : array('HTTP/1.1 400 Bad request');
	$headers = array();
	foreach($http as $key => $value) {
		$value = rtrim($value);
		$t = explode(':', $value, 2);
		if (isset($t[1])) $headers[trim($t[0])] = trim($t[1]);
		else {
			if (preg_match(chr(7).'HTTP/[0-9\.]+\s+([0-9]+)\s(.*)'.chr(7), $value, $matches)) {
				$response['code'] = intval($matches[1]);
				$response['status'] = $matches[2];
			}
		}
	}
	$response['headers'] = $headers;
	return (($response['code'] != 200) && ($response['code'] != 201)
		&& ($response['code'] != 202) && ($response['code'] != 204)) ? FALSE : TRUE;
}

function daemonize($stdin='/dev/null', $stdout='/dev/null', $stderr='/dev/null') {
	// Returns file descriptors in place of file paths for $stdin, $stdout, $stderr

	// Fork, become session leader, fork again, and close open handles so there's no zombie.
	// Open lock file
	if (! ($lock = lock_pid())) return FALSE;

	// Fork. If we get a PID, exit.  If we get 0, we're the child, continue
	if (pcntl_fork()) exit();

	// Dissociate from controlling terminal, become session leader
	if (posix_setsid() === -1) {
		output(ME.' could not setsid.');
		closelog();
		exit(1);
	}
	usleep(100000); // sleep 1/10th of a second

	// Fork again as session leader to be free of other processes. If pcntl_fork
	// returns 0 we're the child, else we're the parent getting the PID of the child
	$childpid = pcntl_fork();
	if ($childpid) {
		// If we get here, we're the parent, write the child's PID to pidfile.
		output(ME.' daemonizing.');
		fseek($lock, 0);
		ftruncate($lock, 0);
		fwrite($lock, $childpid);
		fflush($lock);
		exit();
	}
    else {
		// If we get here, we're the child, finally independent. Grab lockfile.
		usleep(100000); // sleep 1/10th of a second
		flock($lock, LOCK_EX | LOCK_NB);
	}

	// As we are a daemon, close standard file descriptors.
	// http://andytson.com/blog/2010/05/daemonising-a-php-cli-script-on-a-posix-system/
	// When a standard file descriptor is closed, it can be replaced.
	// Create new standard file descriptors in case anything tries to use them.
	// Variable names are not important, but do not re-order the fopens
	$GLOBALS['STDIN'] = fopen($stdin, 'r'); // set fd0
	$GLOBALS['STDOUT'] = fopen($stdout, 'w'); // set fd1
	if ($stdout == $stderr) $GLOBALS['STDERR'] = 'php://stdout'; // hack to duplicate fd1 to fd2
	else $GLOBALS['STDERR'] = fopen($stderr, 'w'); // set fd2 or set fd2 to fd1

	// Ignore some signals we don't care about
	pcntl_signal(SIGTSTP, SIG_IGN);
	pcntl_signal(SIGTTOU, SIG_IGN);
	pcntl_signal(SIGTTIN, SIG_IGN);

	return $lock;
}

function lock_pid($lockpath=FALSE) {
	if (! $lockpath) $lockpath = LOCKFILE;
	$lock = fopen($lockpath, 'c+');
	if (! flock($lock, LOCK_EX | LOCK_NB)) return FALSE;
	ftruncate($lock, 0);
	rewind($lock);
	fwrite($lock, getmypid());
	return $lock;
}

function valid_user_updated(&$dbhandle, $user, $password, $remoteip, &$hostname) {
	// Validates the user, updates DNS record, returns FALSE or TRUE for success or 2 for no change
	// Find the user or fail
	$result = mysqli_query($dbhandle, 'SELECT * FROM `'.UTABLE.'` WHERE `username`="'
		.mysqli_escape_string($dbhandle, $user).'"');
	// Re-open database if necessary
	if (mysqli_errno($dbhandle)) {
		$dbhandle = open_database();
		$result = mysqli_query($dbhandle, 'SELECT * FROM `'.UTABLE.'` WHERE `username`="'
			.mysqli_escape_string($dbhandle, $user).'"');
		if (mysqli_errno($dbhandle)) return FALSE; // 2006 = MySQL server has gone away
	}
	if (! mysqli_num_rows($result)) return FALSE;
	$row = mysqli_fetch_assoc($result);
	$hostname = $row['hostname'];
	$authfails = $row['authfails'];
	$status = $row['status'];

	// Check password hashes
	$sql = '';
	if ((md5($password) != $row['password']) && (! password_verify($password, $row['password']))) {
		$authfails++;
		if ($authfails >= BLOCKTRIGGER) {
			if ($status == 3) $sql .= '`status`=4 ';
			else $sql .= '`status`=2 ';
		}
		mysqli_query($dbhandle, 'UPDATE `'.UTABLE.'` SET '.$sql.'`authfails`='.$authfails.', '
			.'`authfaillast`=NOW() `authfaillastip`="'.mysqli_escape_string($dbhandle, $remoteip).'", '
			.'WHERE `username`="'.mysqli_escape_string($dbhandle, $user).'"');
			block_ip_evaluate($dbhandle, $remoteip); // Possibly block IP
		$msg = 'authfail: '.$user.': '.$remoteip;
		if (LOGFAILPW || LOGPW) $msg .= ' -> '.$pass;
		log_msg($msg);
		return FALSE;
	}
	elseif (substr($row['password'], 0, 1) != '$') {
		// Re-hash raw MD5 passwords to contemporary standards
		$rehashed = password_hash($password, PASSWORD_DEFAULT);
		$sql .= '`password`="'.mysqli_escape_string($dbhandle, $rehashed).'", ';
	}

	//Update the DNS
	if (DNSACCESS == 'db') $code = dns_update_db($dbhandle, $hostname, $remoteip);
	else $code = dns_update_api($hostname, $remoteip);
	if (($code === TRUE) || ($code == 2)) {
		if ($code == 1) $msg = 'info: update: '.$hostname.' -> '.$remoteip;
		else $msg = 'info: '.$hostname.': IP address not changed.';
		log_msg($msg);
	}

	// Update the user record
	if ($code === TRUE) $sql .= '`lastupdate`=NOW(), ';
	mysqli_query($dbhandle, 'UPDATE `'.UTABLE.'` SET '.$sql.'`authfails`=0, '
		.'`authlast`=NOW(), `authlastip`="'.mysqli_escape_string($dbhandle, $remoteip).'" '
		.'WHERE `username`="'.mysqli_escape_string($dbhandle, $user).'"');
	$msg = 'auth: '.$user.': '.$remoteip;
	if (LOGPW) $msg .= ' -> '.$pass;
	log_msg($msg);

	return $code;
}

function dns_update_db($dbhandle, $hostname, $remoteip) {
	// Update the DNS by directly accessing the database
	// Find id of the zone
	$result = mysqli_query($dbhandle, 'SELECT `id` FROM `domains` WHERE `name`="'
		.mysqli_escape_string($dbhandle, DYNZONE).'"');
	if (! mysqli_num_rows($result)) return FALSE;
	$row = mysqli_fetch_assoc($result);
	$zoneid = $row['id'];
	// Find id of the A record
	$result = mysqli_query($dbhandle, 'SELECT `id`, `content`, `ttl` FROM `records` WHERE `domain_id`='
		.$zoneid.' AND `name`="'.mysqli_escape_string($dbhandle, $hostname).'.'
		.mysqli_escape_string($dbhandle, DYNZONE).'" AND `type`="A"');
	if (! mysqli_num_rows($result)) {
		log_msg('Error: Hostname "'.$hostname.'" not found', TRUE);
		return FALSE;
	}
	$row = mysqli_fetch_assoc($result);
	$recid = $row['id'];
	$recip = $row['content'];
	$recttl = $row['ttl'];
	if (($recip != $remoteip) || ($recttl != DYNTTL)) {
		// Update the A record IP address and TTL
		$result = mysqli_query($dbhandle, 'UPDATE `records` SET `content`="'
			.mysqli_escape_string($dbhandle, $remoteip).'", `ttl`='.DYNTTL.' WHERE `id`='.$recid);
		if ($result === FALSE) return FALSE;
		if (! pdns_soa_increment($dbh, $zoneid)) return FALSE; // update the SOA record
		return TRUE;
	}
	// Nothing changed, note this in return code and do nothing.
	return 2;
}

function pdns_soa_increment($dbh, $zoneid, &$error=FALSE) {
	// Update a zone's SOA record according to its serial type, handles serial numbers that are
	// Unix timestamps (recommended), YYYYMMDDNN (BIND dumbassery), or consecutive,
	// if they are less than November 30, 2005, presumably any zone has been updated since then.
	// Note this may result in consecutive serials being converted to time serials, if the serial
	// exceeds 1,133,326,800 and is less than the current Unixtime.
	$result = mysqli_query($dbh, 'SELECT `id`, `name`, `content` FROM `records` '
		.'WHERE `domain_id`="'.$zoneid.'" and `type`="SOA"');
	if (($result === FALSE) || (! mysqli_num_rows($result))) {
		log_msg('Error: Could not find SOA record for zone', TRUE);
		return FALSE;
	}
	$row = mysqli_fetch_assoc($result);
	// SOA: a.ns.example.com hostmaster.example.com 1693264330 3600 7200 604800 600
	// The method used here should also be kept patched into the in-use version of PowerAdmin.
	// Method is: If serial appears to be a date string of YYYYMMDDNN, use the dumbass method from RFC 1912.
	// If the serial interpreted as a Unix timestamp is either older than Jan 1, 1993, or is greater than the
	// current time, then it is a consecutive serial.  If it is neither of these, then the serial is a Unix timestamp.
	$soa = explode(' ', $row['content']);
	$curserial = $soa[2];
   if ($curserial == 0) return TRUE; // Autoserial, do nothing.
	$name = $row['name'];
	$year = (int) substr($curserial, 0, 4);
	$month = (int) substr($curserial, 4, 2);
	$day = (int) substr($curserial, 6, 2);
	$time = time();
	if (($curserial < $time) || (! checkdate($month, $day, $year))) {
		// If less than 1133326800 (2005-11-30 00:00:00), or greater than current time, consecutive serials
		if (($curserial > $time) || ($curserial < 1133326800)) $serial = $curserial +1;
		else $serial = $time; // DJBDNS standard (sensible) 1719367841
	}
	else {
		// BIND dumbass standard recommended by RFC 1912
		$date = date('Ymd');
		$curdate = substr($curserial, 0, 8); // 20240626
		$i = (int) substr($curserial, 8, 2); // iteration 01
		if ($i == 0) $i = 1;
		if ($date != $curdate) $i = 0;
		$i = $i + 1;
		if ($i > 99) {
			log_msg('SOA Serial revision number too large for YYYYMMDD00 format', TRUE);
			return FALSE;
		}
		$serial = $date.sprintf('%02d', $i);
	}
	$soa[2] = $serial;
	$newsoa = implode(' ', $soa);
	// Update SOA record
	$result = mysqli_query($dbh,
		'UPDATE `records` SET `content`="'.$newsoa.'" WHERE `domain_id`="'.$zoneid.'" AND `type`="SOA"');
	if ($result === FALSE) {
		log_msg('Error: Could not update SOA record for '.$name, TRUE);
		return FALSE;
	}
	return TRUE;
}

function dns_update_api($hostname, $remoteip) {
	// Update the DNS by using the PowerDNS Simple API, which allows decoupling
	// the DNS and database from the dynamic DNS service.

	// Get zone ID
	$data = ['name' => DYNZONE, 'zone' => DYNZONE];
	if (! pdns_api_call(APIURL, 'zones', APIKEY, 'GET', $data, $response)) return FALSE;
	$info = $response['data'];
	// Get the record
	$recorddata = array('zoneid' => $info['zoneid'], 'zone' => DYNZONE, 'name' => $hostname.'.'.DYNZONE, 'type' => 'A');
	$response = FALSE;
	if (! pdns_api_call(APIURL, 'records', APIKEY, 'GET', $recorddata, $response)) {
		if (array_key_exists('error', $response['data'])) {
			log_msg('Error: '.$response['data']['error'].' in '.__FUNCTION__, TRUE);
			return FALSE;
		}
		else {
			log_msg('Error: Unable to get dyndns record from PDNS for '.$hostname
				.' in '.__FUNCTION__.' '.$response['status'], TRUE);
			return FALSE;
		}
	}
	$records = $response['data'];
	// There should only be one record for dynamic DNS A records
	$count = (array_key_exists('records', $records)) ? count($records['records']) : 0;
	if ($count > 1) {
		log_msg('Error: Multiple dyndns records found for '.$hostname, TRUE);
		return FALSE;
	}
	$data = array();
	if ($count) {
		if (($records['records'][0]['content'] == $remoteip) && (DYNTTL == $records['records'][0]['ttl'])) return 2;
		$method = 'PATCH';
		$data['recordid'] = $records['records'][0]['recordid'];
	}
	else $method = 'POST';

	// Add/Update DNS A record
	$data['zoneid'] = $info['zoneid'];
	$data['zone'] = DYNZONE;
	$data['type'] = 'A';
	$data['name'] = $hostname.'.'.DYNZONE;
	$data['content'] = $remoteip;
	$data['ttl'] = DYNTTL;
	if (! pdns_api_call(APIURL, 'records', APIKEY, $method, $data, $response)) {
		log_msg('Error: (code '.$response['code'].') '.$response['status'].' '.$response['data']['error'], TRUE);
		return FALSE;
	}
	return TRUE;
}

function blocked_ip(&$dbhandle, $address) {
	// Checks if IP should be unblocked and unblocks it if so
	// Returns TRUE if IP is blocked
	// Type: 0=not blocked(yet), 1=manually blocked, 2=automatically blocked
	$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.PTABLE.'` WHERE `ip`="'
		.mysqli_escape_string($dbhandle, $address).'"');
	if (mysqli_errno($dbhandle)) {
		$dbhandle = open_database();
		$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.PTABLE.'` WHERE `ip`="'
			.mysqli_escape_string($dbhandle, $address).'"');
		if (mysqli_errno($dbhandle)) return FALSE; // 2006 = MySQL server has gone away
	}
	if (mysqli_num_rows($result) == 0) return FALSE;
	$row = mysqli_fetch_assoc($result);
	$stamp = (is_null($row['timestamp'])) ? 0 : strtotime($row['timestamp']);
	$type = $row['type'];
	if (($type == 2) && (($stamp + BLOCKTIME) < time())) {
		$type = 0;
		mysqli_query($dbhandle, 'DELETE FROM `'.PTABLE.'` WHERE `type`=2 AND `ip`="'
			.mysqli_escape_string($dbhandle, $address).'"');
	}
	return (($type == 1) || ($type == 2)) ? TRUE : FALSE;
}

function blocked_user(&$dbhandle, $user) {
	// Checks if user should be unblocked and unblocks if so
	// Returns TRUE if user is blocked
	// Status: 1=manually disabled, 2=temporarily disabled auth, 3=temporarily disabled login, 4=temp both
	$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.UTABLE.'` WHERE `username`="'
		.mysqli_escape_string($dbhandle, $user).'"');
	if (mysqli_errno($dbhandle)) {
		$dbhandle = open_database();
		$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.UTABLE.'` WHERE `username`="'
			.mysqli_escape_string($dbhandle, $user).'"');
		if (mysqli_errno($dbhandle)) return FALSE; // 2006 = MySQL server has gone away
	}
	if (mysqli_num_rows($result) == 0) return FALSE;
	$row = mysqli_fetch_assoc($result);
	$status = $row['status'];
	$stamp = (is_null($row['authfaillast'])) ? 0 : strtotime($row['authfaillast']);
	if ((($status == 2) || ($status == 4)) && (($stamp + BLOCKTIME) < time())) {
		if ($status == 4) $status = 3;
		else $status = 0;
		mysqli_query($dbhandle, 'UPDATE `'.UTABLE.'` SET `status`='.$status.', `authfails`=0 WHERE '
			.'`username`="'.mysqli_escape_string($dbhandle, $user).'"');
	}
	return (($status == 1) || ($status == 2) || ($status == 4)) ? $status : FALSE;
}

function block_ip_evaluate(&$dbhandle, $address) {
	// Updates failure count, and/or blocks $address, in dynpolicy table
	// Also resets and resumes counting failures if a block has expired.
	$result = @mysqli_query($dbhandle, 'SELECT `id`, `count`, `type`, `timestamp` FROM `'.PTABLE.'` WHERE `ip`="'
		.mysqli_escape_string($dbhandle, $address).'"');
	if (mysqli_errno($dbhandle)) {
		$dbhandle = open_database();
		$result = @mysqli_query($dbhandle, 'SELECT `id`, `count`, `type`, `timestamp` FROM `'.PTABLE.'` WHERE `ip`="'
			.mysqli_escape_string($dbhandle, $address).'"');
		if (mysqli_errno($dbhandle)) return FALSE; // 2006 = MySQL server has gone away
	}
	if (mysqli_num_rows($result) != 0) {
		$row = mysqli_fetch_assoc($result);
		$stamp = (is_null($row['timestamp'])) ? 0 : strtotime($row['timestamp']);
		$type = $row['type'];
		if (($type == 2) && (($stamp + BLOCKTIME) < time())) {
			$type = 0;
			$row['count'] = 1;
		}
		else {
			$row['count']++;
			if ($row['count'] >= BLOCKTRIGGER) $type = 2;
		}
		mysqli_query($dbhandle, 'UPDATE `'.PTABLE.'` SET `timestamp`=NOW(), `type`='.$type.', '
			.'`count`='.$row['count'].' WHERE `ip`="'.mysqli_escape_string($dbhandle, $address).'"');
	}
	else {
		mysqli_query($dbhandle, 'INSERT INTO `'.PTABLE.'` (`timestamp`, `type`, `ip`, `count`) '
			.'VALUES(NOW(), 0, "'.mysqli_escape_string($dbhandle, $address).'", 1)');
	}
	return TRUE;
}

function unblock_ip(&$dbhandle, $address) {
	// Used by --unblock, removes permanent and temporary IP blocks
	$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.PTABLE.'` WHERE `ip`="'
		.mysqli_escape_string($dbhandle, $address).'"');
	if (mysqli_errno($dbhandle)) {
		$dbhandle = open_database();
		$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.PTABLE.'` WHERE `ip`="'
			.mysqli_escape_string($dbhandle, $address).'"');
		if (mysqli_errno($dbhandle)) return FALSE; // 2006 = MySQL server has gone away
	}
	if (mysqli_num_rows($result) != 0) {
		mysqli_query($dbhandle, 'DELETE FROM `'.PTABLE.'` WHERE `ip`="'.mysqli_escape_string($dbhandle, $ip).'"');
	}
	return TRUE;
}

function block_ip(&$dbhandle, $address) {
	// Used by --block, permanently blocks an IP address
	$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.PTABLE.'` WHERE `ip`="'
		.mysqli_escape_string($dbhandle, $address).'"');
	if (mysqli_errno($dbhandle)) {
		$dbhandle = open_database();
		$result = @mysqli_query($dbhandle, 'SELECT * FROM `'.PTABLE.'` WHERE `ip`="'
			.mysqli_escape_string($dbhandle, $address).'"');
		if (mysqli_errno($dbhandle)) return FALSE; // 2006 = MySQL server has gone away
	}
	if (mysqli_num_rows($result) != 0) {
		$row = mysqli_fetch_assoc($result);
		mysqli_query($dbhandle, 'UPDATE `'.PTABLE.'` SET `timestamp`=NOW(), `type`=1, '
			.'`count`=0 WHERE `ip`="'.mysqli_escape_string($dbhandle, $address).'"');
	}
	else {
		mysqli_query($dbhandle, 'INSERT INTO `'.PTABLE.'` (`timestamp`, `type`, `ip`, `count`) VALUES(NOW(), 1, "'
			.mysqli_escape_string($dbhandle, $address).'", 0');
	}
	return TRUE;
}

function open_database($wait=TRUE) {
	$dbhandle = mysqli_connect(DBHOST, DBUSER, DBPASS, DBNAME, DBPORT);
	if (mysqli_error($dbhandle)) {
		if ($wait) {
			while ($wait) {
				sleep(10);
				$dbhandle = mysqli_connect(DBHOST, DBUSER, DBPASS, DBNAME, DBPORT);
				if (! mysql_errno($dbhandle)) $wait = FALSE;
			}
		}
		else {
			$msg = 'Can not open database '.DBNAME.' with supplied credentials.';
			log_msg($msg, TRUE);
			closelog();
			exit(1);
		}
	}
	return $dbhandle;
}

function restore_standard_timezone_policy(&$timezone=FALSE) {
	// Being explicitly told what the timezone is, is not a "guess" to be ignored.
	// Make PHP work correctly again this way.  Follow decades long convention to determine
	// timezone, like PHP used to before they broke it deliberately.
	// * Use the explicitly provided timezone data *
	// 1. If application chooses a timezone, use that.
	// 2. Else, if the user's TZ if set, this takes priority.
	// 3. Else, if user has not set their TZ, fall back to the system's time zone.
	// 4. Else, if cannot find system timezone, fall back to UTC
	if (! $timezone) {
		$notset = TRUE;
		$timezone = 'UTC';
		$TZ = getenv('TZ');
		if ($TZ !== FALSE) {
			if (in_array($TZ, DateTimeZone::listIdentifiers())) {
				$notset = FALSE;
				$timezone = $TZ;
			}
			else {
				$error = 'Error: Invalid timezone: '.$TZ;
				if (function_exists('error')) error($error);
				else @fwrite(STDERR, $error."\n");
			}
		}
		if (! stristr(PHP_OS_FAMILY, 'windows')) {
			if ($notset && (file_exists('/etc/timezone'))) {
				// Debian / Ubuntu
				$data = file_get_contents('/etc/timezone');
				if ($data) {
					$notset = FALSE;
					$timezone = trim($data);
				}
			}
			if ($notset && file_exists('/etc/sysconfig/clock')) {
				// RHEL / CentOS
				$data = parse_ini_file('/etc/sysconfig/clock');
				if (! empty($data['ZONE'])) {
					$notset = FALSE;
					$timezone = $data['ZONE'];
				}
			}
			if ($notset && is_link('/etc/localtime')) {
				// Mac OSX (and older Linuxes)
				// /etc/localtime is a symlink to the timezone in /usr/share/zoneinfo or /var/db/timezone/zoneinfo
				$filename = readlink('/etc/localtime');
				if (strpos($filename, '/var/db/timezone/zoneinfo/') === 0) $timezone = substr($filename, 26);
				if (strpos($filename, '/usr/share/zoneinfo/') === 0) $timezone = substr($filename, 20);
			}
		}
		else { // Running under Windows
			$tz = exec('tzutil.exe /g', $out, $err);
			if (! $err) $timezone = intltz_get_id_for_windows_id($tz);
		}
	}
	else {
		if (! in_array($timezone, DateTimeZone::listIdentifiers())) {
			$error = 'Error: Invalid timezone: '.$timezone;
			if (function_exists('error')) error($error);
			else @fwrite(STDERR, $error."\n");
			$timezone = 'UTC';
		}
	}
	return (date_default_timezone_set($timezone)) ? $timezone : FALSE;
}

function formatstr($str, $cols=FALSE) {
	if (defined('COLUMNS') && (! $cols)) $cols = COLUMNS;
	return ($cols) ? wordwrap($str, $cols) : $str;
}

function terminal_init(&$rows=FALSE) {
	//	'tput cols'   tput used to, but no longer returns correct value when invoked by PHP exec()
	//	'resize'      works, not commonly installed, needs to parse: COLUMNS=167;\nLINES=48;\nexport COLUMNS LINES;\n
	// 'stty -a'     works, needs to parse: speed 38400 baud; rows 49; columns 167; line = 0;
	if (defined('COLUMNS')) return COLUMNS;
	$rows = FALSE; $cols = FALSE;
	$out = ''; $return = 0;
	exec('stty -a 2>/dev/null', $out, $return);
	if ($return == 0) {
		$out = strtolower(implode("\n", $out));
		if (FALSE !== preg_match_all("/rows.([0-9]+);.columns.([0-9]+);/", $out, $matches)) {
			$rows = $matches[1][0];
			$cols = $matches[2][0];
		}
	}
	if ($cols == FALSE) {
		$cols = exec('tput cols 2>/dev/null', $out, $return);
		if ($rows) $rows = exec('tput lines 2>/dev/null', $out, $return);
	}
	if (! $cols) $cols = 80;
	if (! defined('COLUMNS')) define('COLUMNS', $cols);
	if ($rows && (! defined('ROWS'))) define('ROWS', $rows);
	return $cols;
}

function help($stderr=FALSE) {
	terminal_init();
	$out = ($stderr === FALSE) ? STDOUT : STDERR;
	$str = ME.' v. '.VERSION;
	$str .= ' PowerTinyDynDNS provides a dynamic DNS service that has a tinydyndns-compatible back end, which is '
		.'to say, a fake POP3 service.  Any POP3 checker/email program is a compatible PowerTinyDynDNS client.  '
		.'PowerTinyDynDNS does not actually handle email, it just uses POP3 protocol to authenticate users, '
		.'and updates a DNS record with the IP address that made the connection.'."\n\n";
	@fwrite($out, formatstr($str, COLUMNS));
	@fwrite($out, formatstr('Usage: '.ME.' [options]'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Options:'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-h|--help] (show this help, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-v|--version] (show version number)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-d|--daemonize] (run '.ME.' as daemon)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-s|--start] (run '.ME.' in foreground, as with systemd)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-S|--stop] (stops a running '.ME.')'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-R|--restart] (restarts a runnning '.ME.')'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-b|--block] (permanently blocks an IP address)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-u|--unblock] (unblocks a temporarily or permanently blocked IP)'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Config file is: powerdyndns.conf.php, or powerdyndns.conf.php in either '
		.'/etc/powerdyndns or /etc/powerdns '."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Please see the man page '.ME.'(1) for more information.'."\n", COLUMNS));
}
