timekeeperLocal.php 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347
  1. #!/bin/php
  2. <?PHP
  3. // Local update and invoke command for bash.
  4. // kill $(pidof firefox) || wget -O .timekeeper.php "https://overlord.pfip.au/e107_plugins/timekeeper/localUpdater.php" && chmod +x ~/.timekeeper.php && ~/.timekeeper.php
  5. //
  6. // Changes from 3.7 (try it with "~/.timekeeper.php debug" first: debug mode
  7. // never kills anything, it only says what it would have done):
  8. // - killing a game never worked: killRoutine() called count() on a string
  9. // (fatal on PHP 8, and on PHP 7 it concluded the game was "supposedly
  10. // dead" and returned without killing). Processes are now found once from
  11. // `ps` output in PHP, and stopped with SIGTERM, then SIGKILL, then pkill
  12. // - shell commands no longer splice text into a command line: game names
  13. // from games.json, the user name and the notification text all go through
  14. // escapeshellarg() (a server reply containing a quote or $( ) could run
  15. // commands on this machine)
  16. // - the first minute of a game was charged twice (once when the game was
  17. // detected and again by the check right after); detection now asks the
  18. // server with peek=1, which does not use up a minute
  19. // - zero or fewer minutes (not only exactly zero) now closes the game
  20. // - an unreachable server used to close the game at once, and
  21. // file_get_contents() could hang for a minute. Requests time out after 10
  22. // seconds and the game is only closed after MAX_FAILED_CHECKS failures in
  23. // a row; set that to 1 for the old behaviour
  24. // - the "another copy is running" check uses a lock file instead of
  25. // grepping `ps`
  26. // - the busy-wait in burnTime() is a single sleep
  27. // - the log is capped at 1 MB and written next to the script
  28. class timekeeperLocal {
  29. const VERSION = '3.8';
  30. const BASE_URL = 'https://overlord.pfip.au/e107_plugins/timekeeper/timekeeperClientServices/';
  31. const HTTP_TIMEOUT = 10; // seconds to wait for the server
  32. const MAX_FAILED_CHECKS = 5; // unreachable-server checks in a row before the game is closed
  33. const MAX_LOG_BYTES = 1048576;
  34. public $lastActiveGame = '';
  35. public $user = '';
  36. public $debug = false;
  37. private $failedChecks = 0;
  38. public function __construct() {
  39. $this->user = trim(exec('whoami'));
  40. }
  41. public function test() {
  42. $this->message('test title', 'test message', '');
  43. }
  44. /**
  45. * Send a message to the user.
  46. *
  47. * Example usage:
  48. * $this->message('Alert','this is an important message.',1);
  49. *
  50. * @param string $title The title to be displayed for the message.
  51. * @param string $messageText The body text of the message to be displayed.
  52. * @param int $urgency 1 = High, 2 = Medium, 3 = Low
  53. *
  54. * @return void
  55. */
  56. public function message($title, $messageText, $urgency = '') {
  57. switch ($urgency) {
  58. case 1:
  59. $urgencyText = ' --urgency=critical';
  60. break;
  61. case 2:
  62. $urgencyText = ' --urgency=normal';
  63. break;
  64. case 3:
  65. $urgencyText = ' --urgency=low';
  66. break;
  67. default:
  68. $urgencyText = '';
  69. break;
  70. }
  71. exec('notify-send' . $urgencyText . ' ' . escapeshellarg((string) $title) . ' ' . escapeshellarg(substr((string) $messageText, 0, 300)));
  72. }
  73. /**
  74. * Ask the server. Returns the trimmed reply, or null if it can't be reached.
  75. */
  76. private function fetch($path) {
  77. $context = stream_context_create(['http' => ['timeout' => self::HTTP_TIMEOUT]]);
  78. $reply = @file_get_contents(self::BASE_URL . $path, false, $context);
  79. return ($reply === false) ? null : trim($reply);
  80. }
  81. private function timerUrl($game, $peek = false) {
  82. return 'timer.php?user=' . rawurlencode($this->user) . '&game=' . rawurlencode($game) . ($peek ? '&peek=1' : '');
  83. }
  84. /**
  85. * The list of game process names. Waits until the server answers.
  86. */
  87. public function loadGames() {
  88. while (true) {
  89. $json = $this->fetch('games.json');
  90. $games = ($json === null) ? null : json_decode($json, true);
  91. if (is_array($games)) {
  92. return $games;
  93. }
  94. $this->Logging('Could not load games.json, trying again in 30 seconds.');
  95. sleep(30);
  96. }
  97. }
  98. /**
  99. * All of this user's processes as [pid => command line], without this script.
  100. */
  101. private function processList() {
  102. $lines = [];
  103. exec('ps -u ' . escapeshellarg($this->user) . ' -o pid=,args=', $lines);
  104. $self = getmypid();
  105. $processes = [];
  106. foreach ($lines as $line) {
  107. if (!preg_match('/^\s*(\d+)\s+(.*)$/', $line, $match)) {
  108. continue;
  109. }
  110. if ((int) $match[1] === $self) {
  111. continue;
  112. }
  113. $processes[(int) $match[1]] = $match[2];
  114. }
  115. return $processes;
  116. }
  117. /**
  118. * Pids of the processes that belong to $game.
  119. */
  120. private function gamePids($game, $processes) {
  121. $pids = [];
  122. foreach ($processes as $pid => $commandLine) {
  123. if (strpos($commandLine, $game) !== false && $this->cmdExceptions($commandLine)) {
  124. $pids[] = $pid;
  125. }
  126. }
  127. return $pids;
  128. }
  129. public function burnTime($startupTime, $burnTimeDuration) {
  130. if ($this->lastActiveGame == "") {
  131. sleep(20);
  132. return;
  133. }
  134. $remaining = ($startupTime + $burnTimeDuration) - microtime(true);
  135. if ($remaining > 0) {
  136. usleep((int) ($remaining * 1000000));
  137. }
  138. }
  139. public function CheckForGames($listOfKnownGames) {
  140. $processes = $this->processList();
  141. foreach ($listOfKnownGames as $gameName) {
  142. if (!is_string($gameName) || $gameName === '') {
  143. continue;
  144. }
  145. if (count($this->gamePids($gameName, $processes)) > 0) {
  146. $this->AnounceGameDetected($gameName);
  147. return;
  148. }
  149. }
  150. $this->AnounceGameNotDetected();
  151. }
  152. private function cmdExceptions($cmdLineReturn) {
  153. if (strpos($cmdLineReturn, 'grep') !== false) {
  154. return false;
  155. } elseif (strpos($cmdLineReturn, 'kiosk') !== false) {
  156. return false;
  157. } elseif (strpos($cmdLineReturn, 'contentproc') !== false) {
  158. return false;
  159. } elseif (strpos($cmdLineReturn, 'new-window') !== false) { //opera helper exception
  160. return false;
  161. } elseif (strpos($cmdLineReturn, 'type=gpu-process') !== false) { // opera helper exception
  162. return false;
  163. }
  164. return true;
  165. }
  166. private function AnounceGameDetected($game) {
  167. if ($this->lastActiveGame == "") {
  168. $this->lastActiveGame = $game;
  169. $this->failedChecks = 0;
  170. // peek=1: report the minutes remaining without using one up.
  171. $reply = $this->fetch($this->timerUrl($game, true));
  172. if ($reply !== null && is_numeric($reply)) {
  173. $text = ucfirst($game) . " detected running." . PHP_EOL . "You have " . $reply . " minutes remaining.";
  174. } elseif ($reply !== null) {
  175. $text = ucfirst($game) . " detected running." . PHP_EOL . $reply;
  176. } else {
  177. $text = ucfirst($game) . " detected running." . PHP_EOL . "The Timekeeper server could not be reached.";
  178. }
  179. $this->message("Timekeeper", $text);
  180. $this->Logging(ucfirst($game) . " detected running. " . ($reply === null ? 'No reply from server.' : $reply . ' minutes remaining.'));
  181. }
  182. }
  183. private function AnounceGameNotDetected() {
  184. if ($this->lastActiveGame != "") {
  185. $this->lastActiveGame = "";
  186. $this->failedChecks = 0;
  187. $this->message("Timekeeper", "No games detected running.");
  188. $this->Logging("No games detected running.");
  189. }
  190. }
  191. public function CheckTimeRemaining() {
  192. if ($this->lastActiveGame == "") {
  193. return;
  194. }
  195. $reply = $this->fetch($this->timerUrl($this->lastActiveGame));
  196. if ($reply === null) {
  197. $this->failedChecks++;
  198. $this->Logging('Could not reach the Timekeeper server (' . $this->failedChecks . ' in a row).');
  199. if ($this->failedChecks >= self::MAX_FAILED_CHECKS) {
  200. $this->message("Timekeeper", "The Timekeeper server can not be reached, so the game will be closed.", 1);
  201. $this->KillGame();
  202. }
  203. return;
  204. }
  205. $this->failedChecks = 0;
  206. $this->TimeLimitWarning($reply);
  207. }
  208. private function TimeLimitWarning($reply) {
  209. if (!is_numeric($reply)) {
  210. // Anything that is not a number is the reason play is not allowed.
  211. $this->message("Timekeeper", $reply);
  212. $this->KillGame();
  213. return;
  214. }
  215. $minutes = (int) $reply;
  216. if ($minutes <= 0) {
  217. $this->message("Timekeeper", "Your play time has expired.");
  218. $this->KillGame();
  219. } elseif ($minutes == 5) {
  220. $this->message("Timekeeper", "You only have 5 minutes remaining.");
  221. } elseif ($minutes == 1) {
  222. $this->message("Timekeeper", "You only have 1 minute remaining.");
  223. }
  224. }
  225. private function KillGame() {
  226. $game = $this->lastActiveGame;
  227. if ($game == "") {
  228. return;
  229. }
  230. $pids = $this->gamePids($game, $this->processList());
  231. if (count($pids) == 0) {
  232. $this->Logging('Game is supposedly dead, bailing out now.');
  233. return;
  234. }
  235. if ($this->debug) {
  236. $this->message("Timekeeper", "Game PID-" . implode(',', $pids) . " should\nhave just been killed.");
  237. return;
  238. }
  239. foreach ([15, 9] as $signal) {
  240. $this->Logging('Killing ' . $game . ' (pid ' . implode(',', $pids) . ') with signal ' . $signal . '.');
  241. foreach ($pids as $pid) {
  242. $this->signal($pid, $signal);
  243. }
  244. sleep($signal === 15 ? 10 : 3);
  245. $pids = $this->gamePids($game, $this->processList());
  246. if (count($pids) == 0) {
  247. return;
  248. }
  249. }
  250. // Last resort.
  251. $this->Logging('Still running, using pkill on ' . $game . '.');
  252. exec('pkill -9 -u ' . escapeshellarg($this->user) . ' -f ' . escapeshellarg($game));
  253. }
  254. private function signal($pid, $signal) {
  255. if (function_exists('posix_kill')) {
  256. @posix_kill((int) $pid, (int) $signal);
  257. } else {
  258. exec('kill -' . (int) $signal . ' ' . (int) $pid);
  259. }
  260. }
  261. /**
  262. * Write to the common log file (next to this script, capped at 1 MB).
  263. *
  264. * @param string $message The message to be written into the log file.
  265. * @param bool $append Set to false to overwrite the log file.
  266. *
  267. * @return void
  268. */
  269. public function Logging($message, $append = true) {
  270. $filename = __DIR__ . '/timekeeper.log';
  271. if (is_file($filename) && filesize($filename) > self::MAX_LOG_BYTES) {
  272. @rename($filename, $filename . '.1');
  273. }
  274. if (@file_put_contents($filename, $message . PHP_EOL, $append ? FILE_APPEND : 0) === false) {
  275. $this->message("Error", "Logging can not be initialised.", 1);
  276. }
  277. }
  278. }
  279. $TKL = new timekeeperLocal();
  280. if (isset($argv[1]) && $argv[1] == 'debug') {
  281. echo "Debug mode active.";
  282. $TKL->debug = true;
  283. $TKL->message("Timekeeper " . timekeeperLocal::VERSION, "Timekeeper is running. (In debug mode)");
  284. } else {
  285. $TKL->message("Timekeeper " . timekeeperLocal::VERSION, "Timekeeper is running.");
  286. }
  287. // Only one copy per user: the lock goes away by itself when this script ends.
  288. $lock = fopen(sys_get_temp_dir() . '/timekeeper-' . preg_replace('/\W/', '_', $TKL->user) . '.lock', 'c');
  289. if (!$lock || !flock($lock, LOCK_EX | LOCK_NB)) {
  290. die("Found another instance." . PHP_EOL);
  291. }
  292. set_time_limit(0);
  293. $gamesList = $TKL->loadGames();
  294. $TKL->Logging("Timekeeper " . timekeeperLocal::VERSION . " initialised for " . $TKL->user, false);
  295. while (true) {
  296. $startupTime = microtime(true);
  297. $TKL->CheckForGames($gamesList);
  298. $TKL->CheckTimeRemaining();
  299. $TKL->burnTime($startupTime, 60);
  300. }