Erebot  latest
A modular IRC bot for PHP 7.0+
CLI.php
1 <?php
2 /*
3  This file is part of Erebot, a modular IRC bot written in PHP.
4 
5  Copyright © 2010 François Poirotte
6 
7  Erebot is free software: you can redistribute it and/or modify
8  it under the terms of the GNU General Public License as published by
9  the Free Software Foundation, either version 3 of the License, or
10  (at your option) any later version.
11 
12  Erebot is distributed in the hope that it will be useful,
13  but WITHOUT ANY WARRANTY; without even the implied warranty of
14  MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
15  GNU General Public License for more details.
16 
17  You should have received a copy of the GNU General Public License
18  along with Erebot. If not, see <http://www.gnu.org/licenses/>.
19 */
20 
21 namespace Erebot;
22 
32 class CLI
33 {
47  public static function startupSighandler($signum)
48  {
49  if (defined('SIGUSR1') && $signum == SIGUSR1) {
50  exit(0);
51  }
52  exit(1);
53  }
54 
68  public static function cleanupPidfile($handle, $pidfile)
69  {
70  flock($handle, LOCK_UN);
71  @unlink($pidfile);
72  $logger = \Plop\Plop::getInstance();
73  $logger->debug(
74  'Removed lock on pidfile (%(pidfile)s)',
75  array('pidfile' => $pidfile)
76  );
77  }
78 
87  public static function run()
88  {
89  // Apply patches.
91 
92  // @HACK Make "Erebot" available as a domain name for translations,
93  // even though no such class really exists.
94  class_alias(__CLASS__, "Erebot", false);
95 
96  // Load the configuration for the Dependency Injection Container.
97  $dic = new \Symfony\Component\DependencyInjection\ContainerBuilder();
98  $dic->setParameter('Erebot.src_dir', __DIR__);
99  $loader = new \Symfony\Component\DependencyInjection\Loader\XmlFileLoader(
100  $dic,
101  new \Symfony\Component\Config\FileLocator(getcwd())
102  );
103 
104  $dicConfig = dirname(__DIR__) .
105  DIRECTORY_SEPARATOR . 'data' .
106  DIRECTORY_SEPARATOR . 'defaults.xml';
107  $dicCwdConfig = getcwd() . DIRECTORY_SEPARATOR . 'defaults.xml';
108  if (!strncasecmp(__FILE__, 'phar://', 7)) {
109  if (!file_exists($dicCwdConfig)) {
110  copy($dicConfig, $dicCwdConfig);
111  }
112  $dicConfig = $dicCwdConfig;
113  } elseif (file_exists($dicCwdConfig)) {
114  $dicConfig = $dicCwdConfig;
115  }
116  $loader->load($dicConfig);
117 
118  // Determine availability of PHP extensions
119  // needed by some of the command-line options.
120  $hasPosix = in_array('posix', get_loaded_extensions());
121  $hasPcntl = in_array('pcntl', get_loaded_extensions());
122 
123  $logger = $dic->get('logging');
124  $translator = $dic->get('translator');
125 
126  // Also, include some information about the version
127  // of currently loaded PHAR modules, if any.
128  $version = 'dev-master';
129  if (!strncmp(__FILE__, 'phar://', 7)) {
130  $phar = new \Phar(\Phar::running(true));
131  $md = $phar->getMetadata();
132  $version = $md['version'];
133  }
134  if (defined('Erebot_PHARS')) {
135  $phars = unserialize(Erebot_PHARS);
136  ksort($phars);
137  foreach ($phars as $module => $metadata) {
138  if (strncasecmp($module, 'Erebot_Module_', 14)) {
139  continue;
140  }
141  $version .= "\n with $module version ${metadata['version']}";
142  }
143  }
144 
145  \Console_CommandLine::registerAction('StoreProxy', '\\Erebot\\Console\\StoreProxyAction');
146  $parser = new \Console_CommandLine(
147  array(
148  'name' => 'Erebot',
149  'description' =>
150  $translator->gettext('A modular IRC bot written in PHP'),
151  'version' => $version,
152  'add_help_option' => true,
153  'add_version_option' => true,
154  'force_posix' => false,
155  )
156  );
157  $parser->accept(new \Erebot\Console\MessageProvider());
158  $parser->renderer->options_on_different_lines = true;
159 
160  $defaultConfigFile = getcwd() . DIRECTORY_SEPARATOR . 'Erebot.xml';
161  $parser->addOption(
162  'config',
163  array(
164  'short_name' => '-c',
165  'long_name' => '--config',
166  'description' => $translator->gettext(
167  'Path to the configuration file to use instead '.
168  'of "Erebot.xml", relative to the current '.
169  'directory.'
170  ),
171  'help_name' => 'FILE',
172  'action' => 'StoreString',
173  'default' => $defaultConfigFile,
174  )
175  );
176 
177  $parser->addOption(
178  'daemon',
179  array(
180  'short_name' => '-d',
181  'long_name' => '--daemon',
182  'description' => $translator->gettext(
183  'Run the bot in the background (daemon).'.
184  ' [requires the POSIX and pcntl extensions]'
185  ),
186  'action' => 'StoreTrue',
187  )
188  );
189 
190  $noDaemon = new \Erebot\Console\ParallelOption(
191  'no_daemon',
192  array(
193  'short_name' => '-n',
194  'long_name' => '--no-daemon',
195  'description' => $translator->gettext(
196  'Do not run the bot in the background. '.
197  'This is the default, unless the -d option '.
198  'is used or the bot is configured otherwise.'
199  ),
200  'action' => 'StoreProxy',
201  'action_params' => array('option' => 'daemon'),
202  )
203  );
204  $parser->addOption($noDaemon);
205 
206  $parser->addOption(
207  'pidfile',
208  array(
209  'short_name' => '-p',
210  'long_name' => '--pidfile',
211  'description' => $translator->gettext(
212  "Store the bot's PID in this file."
213  ),
214  'help_name' => 'FILE',
215  'action' => 'StoreString',
216  'default' => null,
217  )
218  );
219 
220  $parser->addOption(
221  'group',
222  array(
223  'short_name' => '-g',
224  'long_name' => '--group',
225  'description' => $translator->gettext(
226  'Set group identity to this GID/group during '.
227  'startup. The default is to NOT change group '.
228  'identity, unless configured otherwise.'.
229  ' [requires the POSIX extension]'
230  ),
231  'help_name' => 'GROUP/GID',
232  'action' => 'StoreString',
233  'default' => null,
234  )
235  );
236 
237  $parser->addOption(
238  'user',
239  array(
240  'short_name' => '-u',
241  'long_name' => '--user',
242  'description' => $translator->gettext(
243  'Set user identity to this UID/username during '.
244  'startup. The default is to NOT change user '.
245  'identity, unless configured otherwise.'.
246  ' [requires the POSIX extension]'
247  ),
248  'help_name' => 'USER/UID',
249  'action' => 'StoreString',
250  'default' => null,
251  )
252  );
253 
254  try {
255  $parsed = $parser->parse();
256  } catch (\Exception $exc) {
257  $parser->displayError($exc->getMessage());
258  exit(1);
259  }
260 
261  // Parse the configuration file.
262  $config = new \Erebot\Config\Main(
263  $parsed->options['config'],
264  \Erebot\Config\Main::LOAD_FROM_FILE,
265  $translator
266  );
267 
268  $coreCls = $dic->getParameter('core.classes.core');
269  $bot = new $coreCls($config, $translator);
270  $dic->set('bot', $bot);
271 
272  // Use values from the XML configuration file
273  // if there is no override from the command line.
274  $overrides = array(
275  'daemon' => 'mustDaemonize',
276  'group' => 'getGroupIdentity',
277  'user' => 'getUserIdentity',
278  'pidfile' => 'getPidfile',
279  );
280  foreach ($overrides as $option => $func) {
281  if ($parsed->options[$option] === null) {
282  $parsed->options[$option] = $config->$func();
283  }
284  }
285 
286  /* Handle daemonization.
287  * See also:
288  * - http://www.itp.uzh.ch/~dpotter/howto/daemonize
289  * - http://andytson.com/blog/2010/05/daemonising-a-php-cli-script
290  */
291  if ($parsed->options['daemon']) {
292  if (!$hasPosix) {
293  $logger->error(
294  $translator->gettext(
295  'The posix extension is required in order '.
296  'to start the bot in the background'
297  )
298  );
299  exit(1);
300  }
301 
302  if (!$hasPcntl) {
303  $logger->error(
304  $translator->gettext(
305  'The pcntl extension is required in order '.
306  'to start the bot in the background'
307  )
308  );
309  exit(1);
310  }
311 
312  foreach (array('SIGCHLD', 'SIGUSR1', 'SIGALRM') as $signal) {
313  if (defined($signal)) {
314  pcntl_signal(
315  constant($signal),
316  array(__CLASS__, 'startupSighandler')
317  );
318  }
319  }
320 
321  $logger->info(
322  $translator->gettext('Starting the bot in the background...')
323  );
324  $pid = pcntl_fork();
325  if ($pid < 0) {
326  $logger->error(
327  $translator->gettext(
328  'Could not start in the background (unable to fork)'
329  )
330  );
331  exit(1);
332  }
333  if ($pid > 0) {
334  pcntl_wait($dummy, WUNTRACED);
335  pcntl_alarm(2);
336  pcntl_signal_dispatch();
337  exit(1);
338  }
339  $parent = posix_getppid();
340 
341  // Ignore some of the signals.
342  foreach (array('SIGTSTP', 'SIGTOU', 'SIGTIN', 'SIGHUP') as $signal) {
343  if (defined($signal)) {
344  pcntl_signal(constant($signal), SIG_IGN);
345  }
346  }
347 
348  // Restore the signal handlers we messed with.
349  foreach (array('SIGCHLD', 'SIGUSR1', 'SIGALRM') as $signal) {
350  if (defined($signal)) {
351  pcntl_signal(constant($signal), SIG_DFL);
352  }
353  }
354 
355  umask(0);
356  if (umask() != 0) {
357  $logger->warning(
358  $translator->gettext('Could not change umask')
359  );
360  }
361 
362  if (posix_setsid() == -1) {
363  $logger->error(
364  $translator->gettext(
365  'Could not start in the background (unable to create a new session)'
366  )
367  );
368  exit(1);
369  }
370 
371  // Prevent the child from ever acquiring a controlling terminal.
372  // Not required under Linux, but required by at least System V.
373  $pid = pcntl_fork();
374  if ($pid < 0) {
375  $logger->error(
376  $translator->gettext(
377  'Could not start in the background (unable to fork)'
378  )
379  );
380  exit(1);
381  }
382  if ($pid > 0) {
383  exit(0);
384  }
385 
386  // Avoid locking up the current directory.
387  if (!chdir(DIRECTORY_SEPARATOR)) {
388  $logger->error(
389  $translator->gettext('Could not change directory to "%(path)s"'),
390  array('path' => DIRECTORY_SEPARATOR)
391  );
392  }
393 
394  // Explicitly close the magic stream-constants (just in case).
395  foreach (array('STDIN', 'STDOUT', 'STDERR') as $stream) {
396  if (defined($stream)) {
397  fclose(constant($stream));
398  }
399  }
400  // Re-open them with the system's blackhole.
406  $stdin = fopen('/dev/null', 'r');
407  $stdout = fopen('/dev/null', 'w');
408  $stderr = fopen('/dev/null', 'w');
409 
410  if (defined('SIGUSR1')) {
411  posix_kill($parent, SIGUSR1);
412  }
413  $logger->info(
414  $translator->gettext('Successfully started in the background')
415  );
416  }
417 
418  try {
420  $identd = $dic->get('identd');
421  } catch (\InvalidArgumentException $e) {
422  $identd = null;
423  }
424 
425  try {
427  $prompt = $dic->get('prompt');
428  } catch (\InvalidArgumentException $e) {
429  $prompt = null;
430  }
431 
432  // Change group identity if necessary.
433  if ($parsed->options['group'] !== null &&
434  $parsed->options['group'] != '') {
435  if (!$hasPosix) {
436  $logger->warning(
437  $translator->gettext(
438  'The posix extension is needed in order '.
439  'to change group identity.'
440  )
441  );
442  } elseif (posix_getuid() !== 0) {
443  $logger->warning(
444  $translator->gettext(
445  'Only the "root" user may change group identity! '.
446  'Your current UID is %(uid)d'
447  ),
448  array('uid' => posix_getuid())
449  );
450  } else {
451  if (ctype_digit($parsed->options['group'])) {
452  $info = posix_getgrgid((int) $parsed->options['group']);
453  } else {
454  $info = posix_getgrnam($parsed->options['group']);
455  }
456 
457  if ($info === false) {
458  $logger->error(
459  $translator->gettext('No such group "%(group)s"'),
460  array('group' => $parsed->options['group'])
461  );
462  exit(1);
463  }
464 
465  if (!posix_setgid($info['gid'])) {
466  $logger->error(
467  $translator->gettext(
468  'Could not set group identity '.
469  'to "%(name)s" (%(id)d)'
470  ),
471  array(
472  'id' => $info['gid'],
473  'name' => $info['name'],
474  )
475  );
476  exit(1);
477  }
478 
479  $logger->debug(
480  $translator->gettext(
481  'Successfully changed group identity '.
482  'to "%(name)s" (%(id)d)'
483  ),
484  array(
485  'name' => $info['name'],
486  'id' => $info['gid'],
487  )
488  );
489  }
490  }
491 
492  // Change user identity if necessary.
493  if ($parsed->options['user'] !== null ||
494  $parsed->options['user'] != '') {
495  if (!$hasPosix) {
496  $logger->warning(
497  $translator->gettext(
498  'The posix extension is needed in order '.
499  'to change user identity.'
500  )
501  );
502  } elseif (posix_getuid() !== 0) {
503  $logger->warning(
504  $translator->gettext(
505  'Only the "root" user may change user identity! '.
506  'Your current UID is %(uid)d'
507  ),
508  array('uid' => posix_getuid())
509  );
510  } else {
511  if (ctype_digit($parsed->options['user'])) {
512  $info = posix_getpwuid((int) $parsed->options['user']);
513  } else {
514  $info = posix_getpwnam($parsed->options['user']);
515  }
516 
517  if ($info === false) {
518  $logger->error(
519  $translator->gettext('No such user "%(user)s"'),
520  array('user' => $parsed->options['user'])
521  );
522  exit(1);
523  }
524 
525  if (!posix_setuid($info['uid'])) {
526  $logger->error(
527  $translator->gettext(
528  'Could not set user identity '.
529  'to "%(name)s" (%(id)d)'
530  ),
531  array(
532  'name' => $info['name'],
533  'id' => $info['uid'],
534  )
535  );
536  exit(1);
537  }
538  $logger->debug(
539  $translator->gettext(
540  'Successfully changed user identity '.
541  'to "%(name)s" (%(id)d)'
542  ),
543  array(
544  'name' => $info['name'],
545  'id' => $info['uid'],
546  )
547  );
548  }
549  }
550 
551  // Write new pidfile.
552  if ($parsed->options['pidfile'] !== null &&
553  $parsed->options['pidfile'] != '') {
554  $pid = @file_get_contents($parsed->options['pidfile']);
555  // If the file already existed, the bot may already be started
556  // or it may contain data not related to Erebot at all.
557  if ($pid !== false) {
558  $pid = (int) rtrim($pid);
559  if (!$pid) {
560  $logger->error(
561  $translator->gettext(
562  'The pidfile (%(pidfile)s) contained garbage. ' .
563  'Exiting'
564  ),
565  array('pidfile' => $parsed->options['pidfile'])
566  );
567  exit(1);
568  } else {
569  posix_kill($pid, 0);
570  $res = posix_errno();
571  switch ($res) {
572  case 0: // No error.
573  $logger->error(
574  $translator->gettext(
575  'Erebot is already running ' .
576  'with PID %(pid)d'
577  ),
578  array('pid' => $pid)
579  );
580  exit(1);
581 
582  case 3: // ESRCH.
583  $logger->warning(
584  $translator->gettext(
585  'Found stalled PID %(pid)d in pidfile '.
586  '"%(pidfile)s". Removing it'
587  ),
588  array(
589  'pidfile' => $parsed->options['pidfile'],
590  'pid' => $pid,
591  )
592  );
593  @unlink($parsed->options['pidfile']);
594  break;
595 
596  case 1: // EPERM.
597  $logger->error(
598  $translator->gettext(
599  'Found another program\'s PID %(pid)d in '.
600  'pidfile "%(pidfile)s". Exiting'
601  ),
602  array(
603  'pidfile' => $parsed->options['pidfile'],
604  'pid' => $pid,
605  )
606  );
607  exit(1);
608 
609  default:
610  $logger->error(
611  $translator->gettext(
612  'Unknown error while checking for '.
613  'the existence of another running '.
614  'instance of Erebot (%(error)s)'
615  ),
616  array('error' => posix_get_last_error())
617  );
618  exit(1);
619  }
620  }
621  }
622 
623  $pidfile = fopen($parsed->options['pidfile'], 'wt');
624  flock($pidfile, LOCK_EX | LOCK_NB, $wouldBlock);
625  if ($wouldBlock) {
626  $logger->error(
627  $translator->gettext(
628  'Could not lock pidfile (%(pidfile)s). '.
629  'Is the bot already running?'
630  ),
631  array('pidfile' => $parsed->options['pidfile'])
632  );
633  exit(1);
634  }
635 
636  $pid = sprintf("%u\n", getmypid());
637  $res = fwrite($pidfile, $pid);
638  if ($res !== strlen($pid)) {
639  $logger->error(
640  $translator->gettext(
641  'Unable to write PID to pidfile (%(pidfile)s)'
642  ),
643  array('pidfile' => $parsed->options['pidfile'])
644  );
645  exit(1);
646  }
647 
648  $logger->debug(
649  $translator->gettext(
650  'PID (%(pid)d) written into %(pidfile)s'
651  ),
652  array(
653  'pidfile' => $parsed->options['pidfile'],
654  'pid' => getmypid(),
655  )
656  );
657  // Register a callback to remove the pidfile upon exit.
658  register_shutdown_function(
659  array(__CLASS__, 'cleanupPidfile'),
660  $pidfile,
661  $parsed->options['pidfile']
662  );
663  }
664 
665  // Display a desperate warning when run as user root.
666  if ($hasPosix && posix_getuid() === 0) {
667  $logger->warning(
668  $translator->gettext('You SHOULD NOT run Erebot as root!')
669  );
670  }
671 
672  if ($identd !== null) {
673  $identd->connect();
674  }
675 
676  if ($prompt !== null) {
677  $prompt->connect();
678  }
679 
680  // This doesn't return until we purposely
681  // make the bot drop all active connections.
682  $bot->start($dic->get('factory.connection'));
683  exit(0);
684  }
685 }
static startupSighandler($signum)
Definition: CLI.php:47
static cleanupPidfile($handle, $pidfile)
Definition: CLI.php:68
Provides the entry-point for Erebot.
Definition: CLI.php:32
static patch()
Definition: Patches.php:36
static run()
Definition: CLI.php:87