#!/usr/bin/env php
<?php
/**
 * powertinydyndns-users :: IP and user manager.
 *
 * Version 1.0.0, November 16, 2025
 * Copyright (c) 2025, Ron Guerin <ron@vnetworx.net>
 *
 * IP address and User manager for PowerDynDNS and PowerTinyDynDNS.
 *
 * Requires: PHP_PCRE, PHP 7.3+
 *
 * powerdyndns-users 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.
 *
 * powerdyndns-users 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 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; 2025 Ron Guerin
 * @filesource
 * @link http://gothamcode.com/powerdyndns PowerDynDNS
 * @version 1.0.0
 *
 */

error_reporting(E_ALL);
ini_set('display_errors', 1);
define('VERSION', '1.0.0');
define('MEPATH', realpath($argv[0]));
$me = basename(__FILE__);
define('ME', (substr($me, -4) == '.php') ? substr($me, 0, strlen($me) - 4) : $me);
restore_standard_timezone_policy();
define('VERSIONSTAMP', date('F j, Y H:i:s', filemtime(MEPATH)));
openlog(ME, LOG_PID, LOG_USER); // Open syslog
if (function_exists('cli_set_process_title')) cli_set_process_title(ME); // set proctitle
ini_set('precision', 15);

// Parse command-line, early
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;
	}
}

// Parse command-line, later
$exclusiveslist = '';
$error = $found = $skip = $list = $user = $add = $remove = $block = $unblock = $exclusive
	= $exclusives = $password = $genpass = $email = $host = $disabled = $enabled = FALSE;
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 '-a':
		case '--add':
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			$found = TRUE;
			$add = TRUE;
			break;
		case '-r':
		case '--remove':
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			$found = TRUE;
			$remove = TRUE;
			break;
		case '-e':
		case '--enabled':
			$found = TRUE;
			$enabled = TRUE;
			break;
		case '-d':
		case '--disabled':
			$found = TRUE;
			$disabled = TRUE;
			break;
		case '-p':
		case '--password':
			$found = TRUE;
			$password = TRUE;
			break;
		case '-g':
		case '--genpass':
			$found = TRUE;
			$genpass = TRUE;
			break;
		case '-m':
		case '--email':
			if (! $argval) {
				@fwrite(STDERR, 'Error: No email address given for '.$argbase."\n");
				$error = TRUE;
			}
			$email = $argval;
			$skip = TRUE;
			break;
		case '-n':
		case '--hostname':
			if (! $argval) {
				@fwrite(STDERR, 'Error: No hostname given for '.$argbase."\n");
				$error = TRUE;
			}
			$host = $argval;
			$skip = TRUE;
			break;
		case '-l':
		case '--list':
			$exclusiveslist .= $argbase.' ';
			if ($exclusive) $exclusives = TRUE;
			$exclusive = TRUE;
			$found = TRUE;
			$list = 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:
			if (substr($arg, 0, 1) == '-') {
				@fwrite(STDERR, 'Error: Unknown argument "'.$argbase.'"'."\n");
				$error = TRUE;
			}
			elseif (! $user) {
				$found = TRUE;
				$user = $argbase;
				break;
			}
			else {
				@fwrite(STDERR, 'Error: Unknown argument "'.$argbase.'"'."\n");
				$error = TRUE;
			}
			break;
	}
}
if ($list && ($password || $genpass || $email || $host || $add || $remove)) {
	@fwrite(STDERR, 'Error: --list only also allows a username and --enabled or --disabled.'."\n");
	$error = TRUE;
}
if ($add && ((! $user) && ((! $password) && (! $genpass)) || (! $email) || (! $host))) {
	@fwrite(STDERR, 'Error: --add requires a username, either --password or --genpass, --email, and --hostname'."\n");
	$error = TRUE;
}
if ($remove && (! $user)) {
	@fwrite(STDERR, 'Error: --remove requires a username'."\n");
	$error = TRUE;
}
if (((! $list) && (! $add) && (! $remove)) && ($user
	&& ((! $host) && (! $disabled) && (! $enabled) && ((! $password) && (! $genpass))))) {
	@fwrite(STDERR, 'Error: Modifying a username requires at least one of '
		.'--email, --hostname,  --enable, --disable, --password, or --genpass'."\n");
	$error = TRUE;
}
if ($password && $genpass) {
	@fwrite(STDERR, 'Error: Cannot specify both --password and --genpass'."\n");
	$error = TRUE;
}
if ((! $user) && (! $list)) {
	@fwrite(STDERR, 'Error: No username specified'."\n");
	$error = TRUE;
}
if ($exclusives) {
	@fwrite(STDERR, 'Error: Cannot specify together '.rtrim($exclusiveslist)."\n");
	$help = TRUE;
}
if ($error || (! $found)) {
	help(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/powerdns/powerdyndns.conf.php')) $conf = '/etc/powerdns/powerdyndns.conf.php';
elseif (is_readable('/etc/powerdyndns/powertinydyndns.conf.php')) $conf = '/etc/powerdyndns/powertinydyndns.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('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('TTABLE', (isset($tokentable)) ? $tokentable : 'sessiontokens');
define('TOKENTTL', (isset($tokenttl)) ? $tokenttl : 3600); // 1 hour
define('TOKENRENEW', (isset($tokenrenew)) ? $tokenrenew : 300); // Renew if < 5 min left
define('DYNZONE', (isset($dynzone)) ? $dynzone : FALSE);
define('DYNTTL', (isset($dynttl)) ? $dynttl : 300); // 300 seconds = 5 minutes
define('DNSACCESS', (isset($dnsaccess)) ? $dnsaccess : 'db'); // db or api
define('APIKEY', (isset($pdnsapikey)) ? $pdnsapikey : FALSE);
define('APIURL', (isset($pdnsapiurl)) ? $pdnsapiurl : FALSE);

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;
}

pcntl_signal(SIGINT,  'sig_handler');    // terminate
pcntl_signal(SIGTERM, 'sig_handler');    // terminate
pcntl_async_signals(TRUE);               // process signals immediately
register_shutdown_function('shutdown');

if ($password) {
	$password = prompt_hidden_input('Password: ');
	// If terminal user, prompt user to enter password a second time for comparison
	if (posix_isatty(STDIN) && ($password !== prompt_hidden_input('Retype password: '))) {
		@fwrite(STDERR, 'Sorry, passwords do not match.'."\n");
		exit(2);
	}
}
elseif ($genpass) $password = password_generate(16, 'web');
if ($password) $hash = password_hash($password, PASSWORD_DEFAULT);

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

// List users
if ($list) {
	// Build query based on filters
	$sql = 'SELECT `username`, `email`, `hostname`, `status`, `authlast` FROM `'.UTABLE.'`';
	$where = array();
	$types = '';
	$params = array();

	if ($user) {
		$where[] = '`username`=?';
		$types .= 's';
		$params[] = $user;
	}
	if ($enabled) $where[] = '`status`=0';
	elseif ($disabled) $where[] = '`status`!=0';

	if (!empty($where)) $sql .= ' WHERE '.implode(' AND ', $where);

	$sql .= ' ORDER BY `username`';

	if (empty($params)) $result = mysqli_query($dbhandle, $sql);
	else {
		$stmt = mysqli_prepare($dbhandle, $sql);
		$bind_params = array($stmt, $types);
		foreach ($params as $key => $value) $bind_params[] = &$params[$key];
		call_user_func_array('mysqli_stmt_bind_param', $bind_params);
		mysqli_stmt_execute($stmt);
		$result = mysqli_stmt_get_result($stmt);
	}

	if (!$result) {
		@fwrite(STDERR, 'Error: Failed to retrieve users: '.mysqli_error($dbhandle)."\n");
		mysqli_close($dbhandle);
		exit(1);
	}

	$count = mysqli_num_rows($result);

	if ($count == 0) {
		if ($enabled) echo 'No enabled users found.'."\n";
		elseif ($disabled) echo 'No disabled users found.'."\n";
		else echo 'No users found.'."\n";
	}
	else {
		// Calculate column widths
		$max_user = strlen('Username');
		$max_email = strlen('Email');
		$max_host = strlen('Hostname');
		$max_auth = strlen('Last Auth');

		$rows = array();
		while ($row = mysqli_fetch_assoc($result)) {
			$rows[] = $row;
			if (strlen($row['username']) > $max_user) $max_user = strlen($row['username']);
			if (strlen($row['email']) > $max_email) $max_email = strlen($row['email']);
			if (strlen($row['hostname']) > $max_host) $max_host = strlen($row['hostname']);
			if (is_null($row['authlast'])) $row['authlast'] = '';
			if (strlen($row['authlast']) > $max_auth) $max_auth = strlen($row['authlast']);
		}

		// Print header
		$header = sprintf('%-'.$max_user.'s  %-'.$max_email.'s  %-'
			.$max_host.'s  %s   %s', 'Username', 'Email', 'Hostname', 'Status ', 'Last Auth          ');
		echo $header."\n";
		echo str_repeat('-', strlen($header))."\n";

		// Print rows
		foreach ($rows as $row) {
			if ($row['authlast'] == 0) $stamp = '(never)';
			else $stamp = $row['authlast'];
			$status = ($row['status'] == 0) ? 'Enabled '
				: (($row['status'] == 1) ? 'Disabled' : (($row['status'] == 2) ? 'Temp ban' : ''));
			printf('%-'.$max_user.'s  %-'.$max_email.'s  %-'.$max_host.'s  %s  %s'."\n",
				$row['username'], $row['email'], $row['hostname'], $status, $stamp);
		}

		echo "\n".'Total: '.$count.' user'.($count == 1 ? '' : 's')."\n";
	}

	if (isset($stmt)) mysqli_stmt_close($stmt);
	mysqli_close($dbhandle);
	exit(0);
}

// Handle user operations
if ($add) {
	// Check if user already exists
	$stmt = mysqli_prepare($dbhandle, 'SELECT `id` FROM `'.UTABLE.'` WHERE `username`=?');
	mysqli_stmt_bind_param($stmt, 's', $user);
	mysqli_stmt_execute($stmt);
	mysqli_stmt_store_result($stmt);

	if (mysqli_stmt_num_rows($stmt) > 0) {
		@fwrite(STDERR, 'Error: User "'.$user.'" already exists.'."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	mysqli_stmt_close($stmt);

	// Check if hostname already exists
	$stmt = mysqli_prepare($dbhandle, 'SELECT `id` FROM `'.UTABLE.'` WHERE `hostname`=?');
	mysqli_stmt_bind_param($stmt, 's', $host);
	mysqli_stmt_execute($stmt);
	mysqli_stmt_store_result($stmt);

	if (mysqli_stmt_num_rows($stmt) > 0) {
		@fwrite(STDERR, 'Error: Hostname "'.$host.'" already exists.'."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	mysqli_stmt_close($stmt);

	// Update the DNS
	if ((DNSACCESS == 'db') && (! dns_update_db($dbhandle, $host, 'add'))) {
		@fwrite(STDERR, 'Error: Could not add hostname to DNS via DB.'."\n");
		exit(1);
	}
	elseif ((DNSACCESS == 'api') && (! dns_update_api($host, 'add'))) {
		@fwrite(STDERR, 'Error: Could add remove hostname to DNS via API.'."\n");
		exit(1);
	}

	// Insert new user (status 0 = enabled by default)
	$status = ($disabled) ? 1 : 0;
	$stmt = mysqli_prepare($dbhandle, 'INSERT INTO `'.UTABLE.'` '
		.'(`username`, `email`, `hostname`, `password`, `status`) VALUES (?, ?, ?, ?, ?)');
	mysqli_stmt_bind_param($stmt, 'ssssi', $user, $email, $host, $hash, $status);

	if (! mysqli_stmt_execute($stmt)) {
		@fwrite(STDERR, 'Error: Failed to add user: '.mysqli_error($dbhandle)."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	mysqli_stmt_close($stmt);
}
elseif ($remove) {
	// Remove user
	$stmt = mysqli_prepare($dbhandle, 'SELECT `id`, `hostname` FROM `'.UTABLE.'` WHERE `username`=?');
	mysqli_stmt_bind_param($stmt, 's', $user);
	mysqli_stmt_execute($stmt);
	$result = mysqli_stmt_get_result($stmt);
	if (mysqli_num_rows($result) == 0) {
		@fwrite(STDERR, 'Error: User "'.$user.'" does not exist.'."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	$row = mysqli_fetch_assoc($result);
	mysqli_free_result($result);
	$hostname = $row['hostname'];
	mysqli_stmt_close($stmt);

	// Delete user
	$stmt = mysqli_prepare($dbhandle, 'DELETE FROM `'.UTABLE.'` WHERE `username`=?');
	mysqli_stmt_bind_param($stmt, 's', $user);

	if (! mysqli_stmt_execute($stmt)) {
		@fwrite(STDERR, 'Error: Failed to remove user: '.mysqli_error($dbhandle)."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	mysqli_stmt_close($stmt);

	// Update the DNS
	if ((DNSACCESS == 'db') && (! dns_update_db($dbhandle, $hostname, 'delete'))) {
		@fwrite(STDERR, 'Warning: Could not remove hostname from DNS via DB.'."\n");
	}
	elseif ((DNSACCESS == 'api') && (! dns_update_api($hostname, 'delete'))) {
		@fwrite(STDERR, 'Warning: Could not remove hostname from DNS via API.'."\n");
	}

}
else {
	// Modify existing user
	$stmt = mysqli_prepare($dbhandle, 'SELECT `id` FROM `'.UTABLE.'` WHERE `username`=?');
	mysqli_stmt_bind_param($stmt, 's', $user);
	mysqli_stmt_execute($stmt);
	mysqli_stmt_store_result($stmt);

	if (mysqli_stmt_num_rows($stmt) == 0) {
		@fwrite(STDERR, 'Error: User "'.$user.'" does not exist.'."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	mysqli_stmt_close($stmt);

	// Build UPDATE query based on what needs to be modified
	$updates = array();
	$types = '';
	$params = array();

	if ($password) {
		$updates[] = '`password`=?';
		$types .= 's';
		$params[] = $hash;
	}

	if ($email) {
		$updates[] = '`email`=?';
		$types .= 's';
		$params[] = $email;
	}

	if ($host) {
		// Get the user record
		$stmt = mysqli_prepare($dbhandle, 'SELECT `hostname` FROM `'.UTABLE.'` WHERE `username`=?');
		mysqli_stmt_bind_param($stmt, 's', $user);
		mysqli_stmt_execute($stmt);
		$result = mysqli_stmt_get_result($stmt);
		$row = mysqli_fetch_assoc($result);
		mysqli_free_result($result);
		$oldhost = $row['hostname'];

		if ($oldhost == $host) @fwrite(STDERR, 'Warning: Hostname unchanged.'."\n");
		else {
			// Check if hostname already exists for another user
			$stmt = mysqli_prepare($dbhandle, 'SELECT `id` FROM `'.UTABLE.'` WHERE `hostname`=? AND `username`!=?');
			mysqli_stmt_bind_param($stmt, 'ss', $host, $user);
			mysqli_stmt_execute($stmt);
			mysqli_stmt_store_result($stmt);

			if (mysqli_stmt_num_rows($stmt) > 0) {
				@fwrite(STDERR, 'Error: Hostname "'.$host.'" is already in use by another user.'."\n");
				mysqli_stmt_close($stmt);
				mysqli_close($dbhandle);
				exit(1);
			}
			mysqli_stmt_close($stmt);

			$updates[] = '`hostname`=?';
			$types .= 's';
			$params[] = $host;

			// Update the DNS
			if ((DNSACCESS == 'db') && (! dns_update_db($dbhandle, $oldhost, 'update', $host))) {
				@fwrite(STDERR, 'Error: Could not change hostname in DNS via DB.'."\n");
				exit(1);
			}
			elseif ((DNSACCESS == 'api') && (! dns_update_api($oldhost, 'update', $host))) {
				@fwrite(STDERR, 'Error: Could not change hostname in DNS via API.'."\n");
				exit(1);
			}
		}
	}

	if ($disabled) $updates[] = '`status`=1';
	if ($enabled) $updates[] = '`status`=0';

	if (empty($updates)) {
		@fwrite(STDERR, 'Error: No modifications specified.'."\n");
		mysqli_close($dbhandle);
		exit(1);
	}

	// Add username to params for WHERE clause
	$types .= 's';
	$params[] = $user;

	// Execute UPDATE
	$sql = 'UPDATE `'.UTABLE.'` SET '.implode(', ', $updates).' WHERE `username`=?';
	$stmt = mysqli_prepare($dbhandle, $sql);

	// Bind parameters dynamically
	$bindparams = array($stmt, $types);
	foreach ($params as $key => $value) $bindparams[] = &$params[$key];
	call_user_func_array('mysqli_stmt_bind_param', $bindparams);

	if (! mysqli_stmt_execute($stmt)) {
		@fwrite(STDERR, 'Error: Failed to modify user: '.mysqli_error($dbhandle)."\n");
		mysqli_stmt_close($stmt);
		mysqli_close($dbhandle);
		exit(1);
	}
	mysqli_stmt_close($stmt);
}

// Close database connection
mysqli_close($dbhandle);

if ($genpass) echo 'Generated password for "'.$user.'": '.$password."\n";

exit;


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


function shutdown() {
	// Use sighandler to ensure this runs on ^C and SIGTERM
	if (! function_exists('readline_callback_handler_install')) system('stty echo'); // turn on echo
}

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)) 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)) 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 sig_handler($signo) {
	switch ($signo) {
		case SIGTERM:
		case SIGINT:
			// handle shutdown tasks
			exit;
			break;
	}
}

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

function dns_update_db($dbh, $hostname, $action, $newhostname=FALSE) {
	// Update the DNS by directly accessing the database

	// Validate input
	if (($action == 'update') && (trim($newhostname) == '')) {
		@fwrite(STDERR, 'Error: No new hostname given for "'.$action.'" in '.__FUNCTION__."\n");
		return FALSE;
	}

	$fqdn = $hostname.'.'.DYNZONE;
	$newfqdn = $newhostname.'.'.DYNZONE;
	// Find id of the zone
	$result = mysqli_query($dbh, 'SELECT `id` FROM `domains` WHERE `name`="'
		.mysqli_escape_string($dbh, DYNZONE).'"');
	if (! mysqli_num_rows($result)) {
		@fwrite(STDERR, 'Error: Cannot find '.DYNZONE.' in '.__FUNCTION__."\n");
		return FALSE;
	}
	$row = mysqli_fetch_assoc($result);
	$zoneid = $row['id'];
	if (($action == 'delete') || ($action == 'update')) { // get the record
		$result = mysqli_query($dbh, 'SELECT `id` FROM `records` WHERE `domain_id`='.$zoneid.' '
			.'AND `name`="'.mysqli_escape_string($dbh, $fqdn).'"');
		if (! mysqli_num_rows($result)) {
			@fwrite(STDERR, 'Error: Cannot find host record for "'.$hostname.'" in '.__FUNCTION__."\n");
			return FALSE;
		}
		$row = mysqli_fetch_assoc($result);
		$recid = $row['id'];
	}
	if ($action == 'add') { // add the record
		$query = 'INSERT INTO `records` (`domain_id`, `name`, `type`, `content`, `ttl`, `prio`, `disabled`) '
			.'VALUES ("'.$zoneid.'", "'.mysqli_escape_string($dbh, $fqdn).'", "A", "0.0.0.0", '.DYNTTL.', 0, 0)';
		if (FALSE === mysqli_query($dbh, $query)) return FALSE;
	}
	elseif ($action == 'delete') { // delete the record
		$query = 'DELETE FROM `records` WHERE `domain_id`='.$zoneid.' AND `id`='.$recid;
		if ((FALSE === mysqli_query($dbh, $query)) || mysqli_affected_rows($dbh)) return FALSE;
	}
	elseif ($action == 'update') { // update the record
		if (trim($newhostname) == '') {
			@fwrite(STDERR, 'Error: No new hostname given for "'.$action.'" in '.__FUNCTION__."\n");
			return FALSE;
		}
		$query = 'UPDATE `records` SET `name`="'.mysqli_escape_string($dbh, $newfqdn).'" '
			.'WHERE `domain_id`='.$zoneid.' AND `id`='.$recid;
		if ((FALSE === mysqli_query($dbh, $query)) || (! mysqli_affected_rows($dbh))) return FALSE;
	}
	else {
		@fwrite(STDERR, 'Error: No action "'.$action.'" in '.__FUNCTION__."\n");
		return FALSE;
	}
	if (! pdns_soa_increment($dbh, $zoneid)) return FALSE; // update the SOA record
	return TRUE;
}

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))) {
		@fwrite(STDERR, 'Error: Could not find SOA record for zone'."\n");
		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) {
			@fwrite(STDERR, 'SOA Serial revision number too large for YYYYMMDD00 format'."\n");
			return FALSE;
		}
		$serial = $date.sprintf('%02d', $i);
	}
	$soa[2] = $serial;
	$data['soa'] = implode(' ', $soa);
	// Update SOA record
	$result = mysqli_query($dbh,
		'UPDATE `records` SET `content`="'.$data['soa'].'" WHERE `domain_id`="'.$zoneid.'" AND `type`="SOA"');
	if ($result === FALSE) {
		@fwrite(STDERR, 'Error: Could not update SOA record for '.$name."\n");
		return FALSE;
	}
	return TRUE;
}

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

	// Validate input
	if (($action == 'update') && (trim($newhostname) == '')) {
		@fwrite(STDERR, 'Error: No new hostname given for "'.$action.'" in '.__FUNCTION__."\n");
		return FALSE;
	}

	$fqdn = $hostname.'.'.DYNZONE;
	$newfqdn = $newhostname.'.'.DYNZONE;

	// Get zone ID
	$data = ['name' => DYNZONE, 'zone' => DYNZONE];
	if (! pdns_api_call(APIURL, 'zones', APIKEY, 'GET', $data, $response)) {
		@fwrite(STDERR, 'Error: Cannot find '.DYNZONE.' in '.__FUNCTION__."\n");
		return FALSE;
	}
	$info = $response['data'];

	if (($action == 'delete') || ($action == 'update')) { // get the record
		$recorddata = array('zoneid' => $info['zoneid'], 'zone' => DYNZONE, 'name' => $fqdn, 'type' => 'A');
		$response = FALSE;
		if (! pdns_api_call(APIURL, 'records', APIKEY, 'GET', $recorddata, $response)) {
			if (array_key_exists('error', $response['data'])) {
				@fwrite(STDERR, 'Error: '.$response['data']['error'].' in '.__FUNCTION__."\n");
				return FALSE;
			}
			else {
				@fwrite(STDERR, 'Error: Unable to get dyndns record from PDNS for '.$hostname
					.' in '.__FUNCTION__.' '.$response['status']."\n");
				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) $recid = $records['records'][0]['recordid'];
		else {
			@fwrite(STDERR, 'Warning: Cannot find host record for "'.$hostname.'"'."\n");
			return FALSE;
		}
	}

	if ($action == 'delete') { // delete the record
		$data['recordid'] = $recid;
		if (! pdns_api_call(APIURL, 'records', APIKEY, 'DELETE', $data, $response)) {
			@fwrite(STDERR, 'Error: (code '.$response['code'].') '.$response['status']
				.' '.$response['data']['error'].' deleting '.$hostname."\n");
			return FALSE;
		}
	}
	elseif ($action == 'add') { // add a record
		$data['zoneid'] = $info['zoneid'];
		$data['zone'] = DYNZONE;
		$data['type'] = 'A';
		$data['name'] = $fqdn;
		$data['content'] = '0.0.0.0';
		$data['ttl'] = DYNTTL;
		if (! pdns_api_call(APIURL, 'records', APIKEY, 'POST', $data, $response)) {
			@fwrite(STDERR, 'Error: (code '.$response['code'].') '.$response['status']
				.' '.$response['data']['error'].' adding '.$hostname."\n");
			return FALSE;
		}
	}
	elseif ($action == 'update') { // update the record
		// Update DNS A record
		$data['zoneid'] = $info['zoneid'];
		$data['recordid'] = $recid;
		$data['name'] = $newfqdn;
		if (! pdns_api_call(APIURL, 'records', APIKEY, 'PATCH', $data, $response)) {
			@fwrite(STDERR, 'Error: (code '.$response['code'].') '.$response['status'].' '.$response['data']['error']."\n");
			return FALSE;
		}
		return TRUE;
	}
	else {
		@fwrite(STDERR, 'Error: No action "'.$action.'" in '.__FUNCTION__."\n");
		return FALSE;
	}
	return TRUE;
}

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!';
		@fwrite(STDERR, 'Error: '.$msg."\n");
		$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 password_generate($length=16, $type='system') {
	// Generates a random password, web and system exclude BI0OQSZol, web and redis excludes special characters
	if ($length === FALSE) $length=16;
	$chars =  'ACDEFGHJKLMNPRTUVWXYabcdefghijkmnpqrstuvwxyz123456789';
	if (($type != 'system') && ($type != 'api') && ($type != 'web') && ($type != 'db') && ($type != 'redis')) {
		@fwrite(STDERR, 'Error: Password type must be "system", "web", "db", "redis", or "api" in '.__FUNCTION_."\n");
		return FALSE;
	}
	if (($type == 'system') || ($type == 'api') || ($type == 'db')) $chars .= '`-=~!@#%^&*_+,./<>?|';
	if (($type == 'api') || ($type == 'db') || ($type == 'redis')) $chars .= 'BIQSZ01Ool';
	$str = '';
	$max = strlen($chars) - 1;
	for ($i=0; $i < $length; $i++) $str .= $chars[random_int(0, $max)];
	return $str;
}

function prompt_hidden_input($prompt='', $mask='', $nopaste=FALSE) {
	// Prompts for hidden input, supports masking character under readline extension
	// This combines two ideas: hiding input received manually from a user
	// and figuring out whether the input was piped in or not
	// see: https://stackoverflow.com/a/51747444
	// Usage:
	// 	$password = prompt_hidden_input('Password: ');
	//		If terminal user, prompt user to enter password a second time for comparison
	// 	if (posix_isatty(STDIN) && ($password !== prompt_hidden_input('Retype password: '))) {
	//			error('Sorry, passwords do not match.');
	//			exit(2);
	// 	}
	if (! stream_isatty(STDIN)) { // If not a terminal, look to see if input piped in.
		stream_set_blocking(STDIN, FALSE);
		$hiddeninput = trim(fgets(STDIN));
		stream_set_blocking(STDIN, TRUE);
		if ($hiddeninput) return $hiddeninput; // If pipe, don't prompt, just return input.
	}
	if (stristr(PHP_OS_FAMILY, 'windows')) { // Windows
		// please set the path to your powershell, here it is: C:\Windows\system32\WindowsPowerShell\v1.0\powershell.exe
		$hiddeninput = shell_exec('powershell.exe -Command "$hiddeninput=Read-Host -assecurestring \"'.$prompt.'\";'
			.'$plaininput=[System.Runtime.InteropServices.Marshal]::PtrToStringAuto([System.Runtime.InteropServices.Marshal]'
			.'::SecureStringToBSTR($hiddeninput));echo $plaininput;"');
		$hiddeninput = explode("\n", $hiddeninput);
		$hiddeninput = $hiddeninput[0];
	}
	else { // Linux, BSD, OSX
		if (function_exists('readline_callback_handler_install')) { // use readline extension if available
			if (! is_string($mask) || trim($mask) == '') chr(0);
			$mask = substr($mask, 0, 1);
			readline_callback_handler_install($prompt, function(){});
			$hiddeninput = '';
			while (TRUE) {
				$strchar = stream_get_contents(STDIN, 1);
				$intcount=0;
				if ($nopaste) { // Protect against copy and paste passwords
					$read = array(STDIN);
					$write = NULL;
					$except = NULL;
					while (stream_select($read, $write, $except, 0, 0) && in_array(STDIN, $read)) {
						stream_get_contents(STDIN, 1);
						$intcount++;
					}
				}
				if ($strchar === chr(10)) break;
				if ($intcount === 0) {
					if (ord($strchar) === 127) {
						if (strlen($hiddeninput) > 0) {
							$hiddeninput = substr($hiddeninput, 0, strlen($hiddeninput) - 1);
							echo chr(27).chr(91).'D '.chr(27).chr(91).'D';
						}
					}
					elseif ($strchar >= ' ') {
						$hiddeninput .= $strchar;
						echo $mask;
					}
				}
			}
			readline_callback_handler_remove();
		}
		else { // do this the hard way, using stty
			echo $prompt;
			system('stty -echo'); // turn off echo
			$hiddeninput = trim(fgets(STDIN));
			system('stty echo'); // turn on echo
		}
	}
	echo "\n"; // we need a linefeed, since the users LF didn't echo
	return($hiddeninput);
}

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 by again following decades long conventions.
	// * 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 .= ' is a script that manages users of a PowerDynDNS or PowerTinyDynDNS service.';
	@fwrite($out, formatstr($str."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'Usage: '.ME.' [username] [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, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-l|--list] (shows all users or the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-a|--add] (adds the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-r|--remove] (removes the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-e|--email address] (sets email address for the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-n|--hostname hostname] (sets hostname for the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-p|--password] (prompts for a password for the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-g|--genpass] (generates and returns a password for the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-d|--disabled] (disables the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-e|--enabled] (enables the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-b|--block IP] (permanently blocks an IP address)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-u|--unblock IP] (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".'See the man page '.ME.'(1) for more information.'."\n", COLUMNS));
}
