#!/usr/bin/env php
<?php
/**
 * powertinydyndns-control :: PowerDynDNS setup utility.
 *
 * 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-control 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
$error = $skip = $changesoa = $setuptables = 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 '--change-soa-format':
			$changesoa = TRUE;
			break;
		case '--setup-database':
			$setuptables = TRUE;
			break;
		default:
			@fwrite(STDERR, 'Error: Unknown argument "'.$argbase.'"'."\n");
			$error = TRUE;
			break;
	}
}
if ((! $changesoa) && (! $setuptable)) {
	@fwrite(STDERR, 'Error: Nothing to do specified'."\n");
	$error = TRUE;
}
if ($error) {
	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('SOAFORMAT', (isset($soaformat)) ? $soaformat : FALSE);
define('DYNZONE', (isset($dynzone)) ? $dynzone : FALSE);
define('DNSACCESS', (isset($dnsaccess)) ? $dnsaccess : 'db'); // db or api
define('APIKEY', (isset($pdnsapikey)) ? $pdnsapikey : FALSE);
define('APIURL', (isset($pdnsapiurl)) ? $pdnsapiurl : FALSE);

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

if ($changesoa) {
	// Update the DNS
	if ((DNSACCESS == 'db') && (! dns_update_db($dbh))) {
		@fwrite(STDERR, 'Error: Could not change SOA serial format for '.DYNZONE.' in DNS via DB.'."\n");
		exit(1);
	}
	elseif ((DNSACCESS == 'api') && (! dns_update_api())) {
		@fwrite(STDERR, 'Error: Could not change SOA serial format for '.DYNZONE.' in DNS via API.'."\n");
		exit(1);
	}
}

if ($setuptables) {
	if (! setup_tables($dbh)) {
		@fwrite(STDERR, 'Error: Could not set up/upgrade PowerDynDNS tables.'."\n");
		exit(1);
	}
}

// Close database connection
mysqli_close($dbh);

exit;


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


function setup_tables($dbh) {
	// Installs database tables if they don't exist, or upgrades them if they need upgrading
	$return = TRUE;
	$query = 'CREATE TABLE IF NOT EXISTS `dynusers` (`id` int(11) NOT NULL AUTO_INCREMENT, '
		.'`status` tinyint(1) DEFAULT 0 COMMENT \'0=active, 1=inactive, 2=disabledforauthfails, '
		.'3=disabledforloginfails, 4=disabledforboth\', `username` varchar(64) DEFAULT NULL, '
		.'`email` varchar(128) DEFAULT NULL, `password` varchar(255) DEFAULT NULL, '
		.'`authlast` datetime DEFAULT NULL, `authlastip` varchar(45) DEFAULT NULL, '
		.'`authfails` tinyint(3) DEFAULT NULL, `authfaillast` datetime DEFAULT NULL, '
		.'`authfaillastip` varchar(45) DEFAULT NULL, `loginfails` tinyint(3) unsigned DEFAULT NULL, '
		.'`loginfaillast` datetime DEFAULT NULL, `loginfaillastip` varchar(45) DEFAULT NULL, '
		.'`loginlast` datetime DEFAULT NULL, `loginlastip` varchar(45) DEFAULT NULL, '
		.'`loginprior` datetime DEFAULT NULL, `lastupdate` datetime DEFAULT NULL, '
		.'`hostname` varchar(60) NOT NULL, `resettoken` varchar(64) DEFAULT NULL, '
		.'`resetexpires` int(11) DEFAULT NULL, '
		.'PRIMARY KEY (`id`), UNIQUE KEY `hostname` (`hostname`) USING BTREE, '
		.'UNIQUE KEY `username` (`username`) USING BTREE'
		.') ENGINE=InnoDB AUTO_INCREMENT=31 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;';
	if (! mysqli_query($dbh, $query)) {
		@fwrite(STDERR, 'Error: Could not create `dynusers` table in database '.DBNAME."\n");
		$return = FALSE;
	}

	$query = 'CREATE TABLE IF NOT EXISTS `dyntokens` (`id` INT AUTO_INCREMENT PRIMARY KEY, '
		.'`token` VARCHAR(64) NOT NULL UNIQUE, `username` VARCHAR(40) NOT NULL, '
		.'`ip_address` VARCHAR(45) NOT NULL, `created` DATETIME NOT NULL, '
		.'`expires` DATETIME NOT NULL, `last_activity` DATETIME NOT NULL, INDEX `idx_token` (`token`), '
		.'INDEX `idx_username` (`username`), INDEX `idx_expires` (`expires_at`)'
		.') ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;';
	if (! mysqli_query($dbh, $query)) {
		@fwrite(STDERR, 'Error: Could not create `dyntokens` table in database '.DBNAME."\n");
		$return = FALSE;
	}

	$query = 'CREATE TABLE IF NOT EXISTS `dynpolicy` (`id` int(10) unsigned NOT NULL AUTO_INCREMENT, '
		.'`type` tinyint(3) unsigned NOT NULL, `ip` varchar(45) NOT NULL, `count` int(11) NOT NULL DEFAULT 0, '
		.'`timestamp` datetime NOT NULL, PRIMARY KEY (`id`) UNIQUE KEY `ip_UNIQUE` (`ip`)'
		.') ENGINE=MyISAM DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;';
	if (! mysqli_query($dbh, $query)) {
		@fwrite(STDERR, 'Error: Could not create `dynpolicy` table in database '.DBNAME."\n");
		$return = FALSE;
	}
	return $return;
}

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) {
	// Update the DNS by directly accessing the database

	// 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 (! pdns_change_serial_format($dbh, $zoneid)) return FALSE; // update the SOA record format
	return TRUE;
}

function pdns_change_serial_format($dbh, $zoneid) {
	// 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];
	$name = $row['name'];
	$soa[2] = time();
	$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) {
		@fwrite(STDERR, 'Error: Could not update SOA record for '.$name."\n");
		return FALSE;
	}
	return TRUE;
}

function dns_update_api() {
	// 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)) {
		@fwrite(STDERR, 'Error: Cannot find '.DYNZONE.' in '.__FUNCTION__."\n");
		return FALSE;
	}
	$info = $response['data'];

	$recorddata = array('zoneid' => $info['zoneid'], 'zone' => DYNZONE, 'name' => DYNZONE, 'type' => 'SOA');
	$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 SOA records
	$count = (array_key_exists('records', $records)) ? count($records['records']) : 0;
	if ($count) $recid = $records['records'][0]['recordid'];
	else {
		@fwrite(STDERR, 'Warning: Cannot find SOA record for "'.DYNZONE.'"'."\n");
		return FALSE;
	}
	$soa = explode(' ', $records['records'][0]['content']);
	$curserial = $soa[2];
	$soa[2] = time();
	$newsoa = implode(' ', $soa);
	// Update DNS SOA record
	$ndata['zone'] = DYNZONE;
	$ndata['recordid'] = $recid;
	$ndata['content'] = $newsoa;
	if (! pdns_api_call(APIURL, 'records', APIKEY, 'PATCH', $ndata, $response)) {
		@fwrite(STDERR, 'Error: (code '.$response['code'].') '.$response['status'].' '.$response['data']['error']."\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 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 helps set up PowerDynDNS.';
	@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('  [--change-soa-format] (shows all users or the given user)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [--database-setup] (adds the given user)'."\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));
}
