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