#!/usr/bin/env php
<?php
/**
 * powerdyndnsclient :: A primitive client for PowerDynDNS
 *
 * Version 2.1.0, November 18, 2025
 * Copyright (c) 2012-2025, Ron Guerin <ron@vnetworx.net>
 *
 * This script implements a primitive dynamic DNS client for PowerDynDNS.
 *
 * Requires: PHP_PCRE, PHP 5.3+
 *
 * 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.  This script also supports Optware.
 * Edit /opt/etc/${scriptname}.conf on Optware.
 *
 * To run from cron add this to your crontab:
 *  @reboot php /opt/bin/powerdyndns-client >/dev/null 2>&1 &
 *
 * powerdyndnsclient 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.
 *
 * powerdyndnsclient 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; 2013 Ron Guerin
 * @filesource
 * @link http://gothamcode.com/powerdyndns PowerDynDNS
 * @version 2.1.0
 *
 */

error_reporting(E_ALL);
ini_set('display_errors', 1);
define('VERSION', '2.1.0');
define('MEPATH', realpath($argv[0]));
define('HOMEPATH', homepath());
define('MEHOST', hostname(TRUE));
define('USERID', whoami());
$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);

$found = $skip = $config = FALSE;
// Parse command-line, early
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;
	switch ($argbase) {
		case '-V':
		case '--version':
			echo VERSION."\n";
			exit;
			break;
		case '?':
		case '-?':
		case '-h':
		case '--help':
		case 'help':
			help();
			exit;
			break;
		case '-c':
		case '-config':
			$config = $argval;
			$skip = TRUE;
			break;
		default:
			$found = TRUE;
			break;
	}
}

if (! $config) {
	if (is_link('/etc')) $etc='/opt/etc/'; else $etc='/etc/'; # This is the crude test for Optware
	if (is_readable($etc.ME.'.conf.php')) require_once($etc.ME.'.conf.php');
	if (is_readable(HOMEPATH.'/.config/'.ME.'.conf.php')) require_once(HOMEPATH.'/.config/'.ME.'.conf.php');
}
else {
	if (is_readable($config)) require_once($config);
	else {
		@fwrite(STDERR, 'Error: Cannot read '.$config."\n");
		exit(1);
	}
}

$error = FALSE;
define('DEBUG', (isset($debug) && ($debug === TRUE)) ? TRUE : FALSE);
define('LOCKWAIT', (isset($proclockwait)) ? $proclockwait : 180); // seconds
if (isset($lockfile) && (substr($lockfile, 0, 1) == '~')) $lockfile = HOMEPATH.(substr($lockfile, 1));
define('LOCKFILE', (isset($lockfile)) ? $lockfile : '/tmp/'.ME.'_'.USERID.'.lock');
if (isset($logfile) && (substr($logfile, 0, 1) == '~')) $logfile = HOMEPATH.(substr($logfile, 1));
define('LOGFILE', (isset($logfile)) ? (($logfile === TRUE) ? '/var/log/'.ME.'.log' : $logfile) : FALSE);
define('SYSLOG', ((! isset($syslog)) || (isset($syslog) && ($syslog !== FALSE))) ? TRUE : FALSE);
define('MAILFROM', (isset($mailfrom)) ? $mailfrom : ME.'@'.hostname(TRUE));
define('MAILFROMNAME', (isset($mailfromname)) ? $mailfromname : ME);
define('MAILTO', (isset($mailto)) ? $mailto : 'root@'.hostname(TRUE));
define('MAILTONAME', (isset($mailtoname)) ? $mailtoname : 'PowerTinyDynDNS client administrator');
define('DIRECT', (isset($direct) && ($direct === TRUE)) ? TRUE : FALSE);
define('CHECKIP', (isset($checkip)) ? $checkip : FALSE);
if (isset($saveip) && (substr($saveip, 0, 1) == '~')) $saveip = HOMEPATH.(substr($saveip, 1));
define('SAVEIP', (isset($saveip)) ? $saveip : HOMEPATH.'/.external-IP');
define('DYNHOST', (isset($dynhost)) ? $dynhost : FALSE);
define('DYNUSER', (isset($dynuser)) ? $dynuser : FALSE);
define('DYNPASS', (isset($dynpass)) ? $dynpass : FALSE);
define('DYNDOMAIN', (isset($dyndomain)) ? $dyndomain : FALSE);
define('CYCLE', (isset($cycle)) ? $cycle : 300);
define('ALERTS', (isset($alerts) && ($alerts === TRUE)) ? TRUE : FALSE);

$err = '';
if (! DYNDOMAIN) $err .= '$dynurl, ';
if (! DYNHOST) $err .= '$dynhost, ';
if (! DYNUSER) $err .= '$dynuser, ';
if (! DYNPASS) $err .= '$dynpass, ';
if (! CHECKIP) $err .= '$checkip, ';
if ($err) {
	$err = trim($err, ', ');
	@fwrite(STDERR, 'Error: '.$err.' not defined in config file'."\n");
	$error = TRUE;
}

if ($error) exit(1);

$skip = $ignore = $ip = $host = $force = $once = FALSE;
$targets = array();

// Parse command-line, later
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 '-i':
		case '--ip':
			$ip = TRUE;
			break;
		case '-o':
		case '--once':
			$once = TRUE;
			break;
		case '-f':
		case '--force':
			$force = TRUE;
			break;
		default:
			if (substr($arg, 0, 1) == '-') {
				@fwrite(STDERR, 'Error: Unknown argument "'.$argbase.'"'."\n");
				$error = TRUE;
			}
			else $targets[] = $argbase;
	}
}
if ($error) exit(1);

define('ONCE', (isset($once) && ($once === TRUE)) ? TRUE : FALSE);

if ($ip) {
	echo getmyip(DIRECT, CHECKIP)."\n";
	exit;
}

/* Never edit below this line unless you know what you're doing! */

$lock = fopen(LOCKFILE, 'c+');
if (! flock($lock, LOCK_EX | LOCK_NB)) {
	die ('Couldn\'t open lockfile! '.ME.' probably running already.'."\n");
}
else {
	// Write PID to pidfile
	fseek($lock, 0);
	ftruncate($lock, 0);
	fwrite($lock, getmypid());
	fflush($lock);
}

$oldip = gethostbyname(DYNHOST.'.'.DYNDOMAIN);

if ($force && file_exists(SAVEIP)) unlink(SAVEIP);

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');

if ((! ONCE) && LOGFILE) file_put_contents(LOGFILE, date('Y-m-d H:i:s ').ME.' Starting.'."\n", FILE_APPEND|LOCK_EX);

while (TRUE) {
	$curip = getmyip(DIRECT, CHECKIP);
	if (DEBUG) echo date('Y-m-d H:i:s').' oldip: '.(($oldip) ? $oldip : '(unknown)').' curip: '.$curip."\n";
	if (($curip != $oldip) && ($curip !== FALSE)) {
		$msg = 'IP address changed from '.(($oldip) ? $oldip : '(unknown)').' to '.$curip;
		if (DEBUG && ($oldip != $curip) && ALERTS) mailalert(MAILFROM, MAILTO, 'IP address changed', $msg."\n");
		if (SYSLOG) syslog(LOG_NOTICE, $msg);
		if (LOGFILE) file_put_contents(LOGFILE, date('Y-m-d H:i:s ').$msg."\n", FILE_APPEND|LOCK_EX);
		if (DEBUG) echo ' '.$msg;
		$ret = set_powerdyndns(DYNUSER, DYNPASS, DYNHOST, $curip);
		if ($ret == FALSE) {
			$msg = 'Error setting dynamic DNS on '.php_uname('n').'.';
			if (SYSLOG) syslog(LOG_ERR, $msg);
			if (LOGFILE) file_put_contents(LOGFILE, date('Y-m-d H:i:s ').$msg."\n", FILE_APPEND|LOCK_EX);
			if (DEBUG) echo ' '.$msg;
			if (ALERTS) mailalert(MAILFROM, MAILTO, 'Error setting dynamic DNS.', $msg."\n");
		}
		else {
			file_put_contents(SAVEIP, $curip);
			$oldip = $curip;
		}
	}
	$oldip = (file_exists(SAVEIP) ? trim(file_get_contents(SAVEIP)) : $oldip);
	if ($once) break;
	if (DEBUG) echo ' sleeping'."\n";
	sleep(CYCLE);
}
// We'll never get here though
closelog();
exit;


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


function shutdown() {
	// Use sighandler to ensure this runs on ^C and SIGTERM
	if ((! ONCE) && LOGFILE) file_put_contents(LOGFILE, date('Y-m-d H:i:s ').'Shutting down.'."\n", FILE_APPEND|LOCK_EX);
}

function sig_handler($signo) {
	switch ($signo) {
		case SIGTERM:
		case SIGINT:
			// handle shutdown tasks
			exit;
			break;
	}
}

function getmyip($direct, $checkip) {
	$ip = FALSE;
	if ($direct) {
		$line = exec('ip route get 8.8.8.8|grep -P --only-matching \'src .*\'');
		preg_match(chr(7).'src (.*)'.chr(7), $line, $matches);
		if (array_key_exists(1, $matches)) $ip = $matches[1];
	}
	else {
		$opts = array('http' => array('protocol_version'=> '1.1', 'method' => 'GET',
			'ignore_errors' => TRUE, 'timeout' => 60, 'header' => 'Connection: close'."\r\n",
			'user_agent' => ME.'/'.VERSION, 'follow_location' => TRUE));
		$line = file_get_contents($checkip, FALSE, stream_context_create($opts), FALSE, 4000000);

		if (preg_match(chr(7).'^(?:(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])\.)'
			.'{3}(?:25[0-5]|2[0-4][0-9]|1[0-9]{2}|[1-9]?[0-9])$|^(?:[0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}$|'
			.'^(?:[0-9a-fA-F]{1,4}:){1,7}:[0-9a-fA-F]{1,4}$|^:[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){1,7}$|^::(?:'
			.'[0-9a-fA-F]{1,4}:){0,6}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}::(?:[0-9a-fA-F]{1,4}:){0,5}[0-9a-fA-F]{1,4}$'
			.'|^[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,1}::(?:[0-9a-fA-F]{1,4}:){0,4}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}'
			.'(?::[0-9a-fA-F]{1,4}){0,2}::(?:[0-9a-fA-F]{1,4}:){0,3}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]'
			.'{1,4}){0,3}::(?:[0-9a-fA-F]{1,4}:){0,2}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,4}::(?:'
			.'[0-9a-fA-F]{1,4}:){0,1}[0-9a-fA-F]{1,4}$|^[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,5}::[0-9a-fA-F]{1,4}$|^'
			.'[0-9a-fA-F]{1,4}(?::[0-9a-fA-F]{1,4}){0,6}::$|^::[0-9a-fA-F]{1,4}$|^::$'.chr(7), $line, $matches)) {
			$ip = $matches[0];
		}
	}
	return $ip;
}

function set_powerdyndns($user, $pass, $hostname, $ipaddress) {
	$url = 'https://'.DYNDOMAIN.'/nic/update?hostname='.$hostname.'&myip='.$ipaddress;
	$opts = array('http' => array('protocol_version'=> '1.1', 'method' => 'GET', 'ignore_errors' => TRUE,
		'timeout' => 60, 'header' => 'Connection: close'."\r\n".'Authorization: Basic '
		.base64_encode($user.':'.$pass)."\r\n", 'user_agent' => ME.'/'.VERSION, 'follow_location' => TRUE));
	$response = file_get_contents($url, FALSE, stream_context_create($opts), FALSE, 4000000);
	if ($response == '911') {
		$msg = 'Error: Account failure, contact support.';
		@fwrite(STDERR, $msg."\n");
		if (ALERTS) mailalert(MAILFROM, MAILTO, 'Dynamic DNS account failure', $msg."\n");
	}
	if (($response != 'good') && ($response != 'nochg')) return FALSE;
	return TRUE;
}

function mailalert($mailfrom, $mailto, $subject, $body) {
	ini_set('sendmail_from', $mailfrom);
	$headers  = 'X-Mailer: dyndnsclient dynamic DNS client'."\n";
	$headers .= 'From: "'.ME.' on '.php_uname('n').'" <'.$mailfrom.'>'."\n";
	$mailto = '"'.MAILTONAME.'" <'.$mailto.'>';
	$options = "-f" . $mailfrom;
	mail($mailto, $subject, $body, $headers, $options);
	if (DEBUG) echo ' Mail sent';
}

function homepath() {
	// Try to find out the home directory of the user running this script
	if (function_exists('posix_getpwnam')) {
		// Use POSIX
		$userinfo = posix_getpwnam(whoami());
		$home = rtrim($userinfo['dir'], '/');
	}
	else {
		// Or look for *nix environment variable
		if (empty($home)) $home = @getenv('HOME');
		// Or look for Windows environment variables
		$home = @getenv('HOMEDRIVE').@getenv('HOMEPATH');
		// Or try to get it from a *nix shell
		if (empty($home)) {
			$line = trim(exec('cd ~;pwd', $out, $code));
			if ($code == 0) $home = $line;
		}
		// Or try to get it from a Windows shell
		if (empty($home)) {
			$line = trim(exec('echo %userprofile%', $out, $code));
			if ($code == 0) $home = $line;
		}
		// Or try to get it from a whoami command
		if (empty($home)) {
			$line = trim(exec('whoami', $out, $code));
			if ($code == 0) {
				if ($pos = strstr($line, '\\')) {
					$tmp = 'C:\\Users\\'.substr($line, $pos + 1);
					if (file_exists($tmp) && is_dir($tmp) && is_writable($tmp)) $home = $tmp;
				}
				elseif (stristr(PHP_OS_FAMILY, 'linux')) {
					$tmp = '/home/'.$line;
					if (file_exists($tmp) && is_dir($tmp) && is_writable($tmp)) $home = $tmp;
				}
				elseif (stristr(PHP_OS_FAMILY, 'bsd')) {
					$tmp = '/home/'.$line;
					if (file_exists($tmp) && is_dir($tmp) && is_writable($tmp)) $home = $tmp;
					else {
						$tmp = '/usr/home/'.$line;
						if (file_exists($tmp) && is_dir($tmp) && is_writable($tmp)) $home = $tmp;
					}
				}
				elseif (stristr(PHP_OS_FAMILY, 'darwin')) {
					$tmp = '/Users/'.$line;
					if (file_exists($tmp) && is_dir($tmp) && is_writable($tmp)) $home = $tmp;
				}
			}
		}
	}
	return (empty($home)) ? FALSE : $home; // Return POSIX answer or our best guess
}

function whoami() {
	// Try to find out the username of the user running the script
	if (function_exists('posix_getpwuid')) {
		// Use POSIX
		$userinfo = posix_getpwuid(posix_geteuid());
		$curuser = $userinfo['name'];
	}
	else {
		// Or look for Windows environment variable
		$curuser = getenv('USERNAME');
		// Or look for *nix environment variable
		if (empty($curuser)) $curuser = getenv('USER');
		if (empty($curuser)) {
			// Or try running whoami, which should be on Linux/BSD/Unix/OS X, as well as Windows.
			// Windows may be native or Cygwin.  Native Windows whoami will return: hostname\username
			$curuser = exec('whoami');
			if (! empty($curuser)) {
				// Check for a response from Windows version of whoami and trim off the host and slash if found
				$slashpos = strpos($curuser, '\\');
				if ($slashpos) $curuser = substr($curuser, $slashpos + 1);
			}
		}
	}
	if (empty($curuser)) return FALSE; // We give up
	else return $curuser; // Return POSIX answer or our best guess
}

function hostname($fqdn=FALSE) {
	$hostname = php_uname('n');
	return ($fqdn) ? $hostname : substr($hostname, 0, strpos($hostname, '.'));
}

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 updates a PowerDynDNS dynamic DNS record with '
		.'the user\'s current IPv4 address.';
	@fwrite($out, formatstr($str."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'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, exit)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-c|--config /path/config.php] (path to config file to use)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-f|--force] (force update even if IP unchanged)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-i|--ip] (shows current IPv4 address)'."\n", COLUMNS));
	@fwrite($out, formatstr('  [-o|--once] (runs one cycle and exits)'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'The configuration file is either '
		.'/etc/'.ME.'.conf.php or ~/.config/'.ME.'.conf.php'."\n", COLUMNS));
	@fwrite($out, formatstr("\n".'See man '.ME.'(1) for more information.'."\n", COLUMNS));
}
