You are here

HttpblEvaluator.php in http:BL 8

Namespace

Drupal\httpbl

File

src/HttpblEvaluator.php
View source
<?php

namespace Drupal\httpbl;

use Drupal\httpbl\Entity\Host;
use Drupal\ban\BanIpManagerInterface;
use Drupal\httpbl\Logger\HttpblLogTrapperInterface;

/**
 * Checking/Blocking Options
 */

// "Off" No checking or blocking is enabled.
define('HTTPBL_CHECK_NONE', 0);

// Checking/blocking only comment submissions.
define('HTTPBL_CHECK_COMMENTS', 1);

// Checking/blocking all page requests.
define('HTTPBL_CHECK_ALL', 2);

/**
 * Threat Level Threshold Defaults
 */

// Threat level threshold above which a user is grey-listed.
define('HTTPBL_THRESHOLD_GREY', 1);

// Threat level threshold above which a user is blacklisted.
define('HTTPBL_THRESHOLD_BLACK', 50);

/**
 * Host Status Values
 */

// White-listed locally: no (or low) threat score at Project Honeypot.
define('HTTPBL_LIST_SAFE', 0);

// Grey-listed locally: medium threat score, session-based white-listing
// accepted on challenge.
define('HTTPBL_LIST_GREY', 2);

// Blacklisted locally: high threat, considered entirely undesirable.
define('HTTPBL_LIST_BLACK', 1);

/**
 * Storage Options
 */

// "Off" No storage enabled.
define('HTTPBL_DB_OFF', 0);

// Store results as host entities.
define('HTTPBL_DB_HH', 1);

// "Auto-banning": Store host entities and add blacklisted IPs to Drupal Ban.
define('HTTPBL_DB_HH_DRUPAL', 2);

/**
 * Log Volume
 */

// Quiet: passes error, critical, alert and emergency.
define('HTTPBL_LOG_QUIET', 0);

// Minimal logging (warning & notice) for positive lookups and admin actions.
define('HTTPBL_LOG_MIN', 1);

// Verbose Logging - Everything! Very verbose, including debug and info.
// Recommended only for testing !!!
define('HTTPBL_LOG_VERBOSE', 2);

/**
 * Source Stamps  (source of evaluation)
 */

// Used only when an evaluation is orignally sourced and has
// not been managed by a local admin.
define('HTTPBL_ORIGINAL_SOURCE', 'Project Honeypot');

// Used when a host has been edited.
define('HTTPBL_ADMIN_SOURCE', 'Admin Managed');

// Used when a grey-listed host converts to blacklisted after failing a
// white-list challenge.
define('HTTPBL_CHALLENGE_FAILURE', 'Http:BL Challenged');

// Used (only in log) when a grey-listed host is session white-listed after
// successful white-list challenge.
define('HTTPBL_CHALLENGE_SUCCESS', 'Http:BL Session White-listed');

// Used when a host has been rescued with drush.
define('HTTPBL_DRUSH_SOS_SOURCE', 'Http:BL Drush SOS');

// Used when a host has been created with drush.
define('HTTPBL_DRUSH_CREATED', 'Http:BL Drush makeHosts()');

// Used when a host has been created as banned, with drush.
define('HTTPBL_DRUSH_CREATED_BANNED', 'Http:BL Drush makeBannedHosts()');

/**
 * HttpblEvaluator evaluates visitor/host page requests.
 */
class HttpblEvaluator implements HttpblEvaluatorInterface {

  /**
   * The ban IP manager.
   *
   * @var \Drupal\ban\BanIpManagerInterface
   */
  protected $banManager;

  /**
   * A logger arbitration instance.
   *
   * @var \Drupal\httpbl\Logger\HttpblLogTrapperInterface
   */
  protected $logTrapper;

  /**
   * Construct HttpblEvaluator.
   *
   * @param \Drupal\ban\BanIpManagerInterface $banManager
   *   Core Drupal Ban manager.
   * @param \Drupal\httpbl\Logger\HttpblLogTrapperInterface $logTrapper
   *   A logger arbitration instance.
   */
  public function __construct(BanIpManagerInterface $banManager, HttpblLogTrapperInterface $logTrapper) {
    $this->banManager = $banManager;
    $this->logTrapper = $logTrapper;
  }

  /**
   * {@inheritdoc}
   */
  public function getPageRequestOption() {
    $check_option = (int) \Drupal::state()
      ->get('httpbl.check');
    if ($check_option == HTTPBL_CHECK_ALL) {
      return TRUE;
    }
    return FALSE;
  }

  /**
   * {@inheritdoc}
   *
   * Manages remote and local lookups on visiting host IPs, evaluates their
   * remote status as safe or suspicious and determines a locally stored status
   * (safe / white-listed, grey-listed, or blacklisted) which is used (by other
   * functions) to determine an appropriate, subsequent response to a request.
   */
  public function evaluateVisitor($ip, $request, $project_supported) {

    // Evaluated status that was already locally stored or was calculated based on
    // a score retrieved from Project Honeypot.

    /** @var integer $evaluated_status */
    static $evaluated_status;

    // If not a supported lookup, mark "safe" and log notice.
    // This will avoid any further processing or storage.
    if (!$project_supported) {
      $evaluated_status = HTTPBL_LIST_SAFE;
      $this->logTrapper
        ->trapNotice('HttpBL evaluation not supported for IPv6 @ip.', [
        '@ip' => $ip,
      ]);
    }

    // Evaluated status was already calculated -- return.
    if (is_int($evaluated_status)) {
      $evaluated = [
        'evaluated',
        $evaluated_status,
      ];

      /** @var array $evaluated */
      return $evaluated;
    }

    // Check if visitor already has a white-listed session (granted by the white-list challenge).
    if (self::visitor_whitelisted_session($ip)) {
      $evaluated_status = HTTPBL_LIST_SAFE;
      $this->logTrapper
        ->trapDebug('@ip already session white-listed for request @request.', [
        '@ip' => $ip,
        '@request' => $request
          ->getRequestUri(),
      ]);
      $evaluated = [
        'evaluated',
        $evaluated_status,
      ];
      return $evaluated;
    }
    elseif (\Drupal::state()
      ->get('httpbl.storage') > HTTPBL_DB_OFF) {
      $evaluated_status = $this
        ->getIpLocalStatus($ip);

      //Humanize the status results for any verbose logging.
      $human = 'Not found';
      $status = $evaluated_status;
      if (is_string($evaluated_status)) {
        switch ($evaluated_status) {
          case '0':
            $human = 'white-listed';
            break;
          case '1':
            $human = 'blacklisted';
            break;
          case '2':
            $human = 'greylisted';

            // Prepare to set up a challenge response.
            $_SESSION['httpbl_ip'] = $ip;
            $_SESSION['httpbl_challenge'] = TRUE;
            break;
        }
      }
      $this->logTrapper
        ->trapDebug('Local query for @ip: @human (status = @status).', [
        '@ip' => $ip,
        '@status' => $status,
        '@human' => $human,
      ]);
    }

    // Visitor is not already white listed and not found in Httpbl table, so we'll do a DNS Lookup.
    if (!is_numeric($evaluated_status)) {
      $this->logTrapper
        ->trapDebug('Honeypot DNS Lookup for IP @ip.', [
        '@ip' => $ip,
      ]);

      // Do a Project Honeypot DNS lookup, and continue if lookup was succesful
      if ($response = $this
        ->httpbl_dnslookup($ip)) {
        $stats = \Drupal::state()
          ->get('httpbl.stats') ?: TRUE;
        $black_threshold = \Drupal::state()
          ->get('httpbl.black_threshold') ?: HTTPBL_THRESHOLD_BLACK;
        $grey_threshold = \Drupal::state()
          ->get('httpbl.grey_threshold') ?: HTTPBL_THRESHOLD_GREY;
        $score = $response['threat'];

        //@todo Someday we'll do something with the 'type' response from P.H.

        //$type = $response['type'];

        // Blacklisted?
        // (Is the threat score at Project Honeypot above our threshold?)
        if ($score > $black_threshold && $response['type']) {
          $this->logTrapper
            ->trapWarning('@ip ranked: blacklisted (Threat Score = @score).', [
            '@ip' => $ip,
            '@score' => $score,
            'link' => self::projectLink($ip),
          ]);

          // If settings indicate we are storing results...
          if (\Drupal::state()
            ->get('httpbl.storage') > HTTPBL_DB_OFF) {

            // Store this blacklisted IP.
            $this
              ->setIpLocalStatus($ip, HTTPBL_LIST_BLACK, \Drupal::state()
              ->get('httpbl.blacklist_offset') ?: 31536000);

            // Increment the stats if configured to do so.
            if ($stats) {
              \Drupal::state()
                ->set('httpbl.stat_black', \Drupal::state()
                ->get('httpbl.stat_black') + 1);
            }
          }
          $evaluated_status = HTTPBL_LIST_BLACK;
          $evaluated = [
            'evaluated',
            $evaluated_status,
          ];
          return $evaluated;
        }
        elseif ($score > $grey_threshold && $response['type']) {

          // Prepare to set up a challenge response.
          $_SESSION['httpbl_ip'] = $ip;
          $_SESSION['httpbl_challenge'] = TRUE;
          $this->logTrapper
            ->trapNotice('@ip ranked: grey-listed (Threat Score = @score).', [
            '@ip' => $ip,
            '@score' => $score,
            'link' => self::projectLink($ip),
          ]);

          // Store the results if configured to do so.
          if (\Drupal::state()
            ->get('httpbl.storage') > HTTPBL_DB_OFF) {
            $this
              ->setIpLocalStatus($ip, HTTPBL_LIST_GREY, \Drupal::state()
              ->get('httpbl.greylist_offset') ?: 86400);

            // Increment the stats if configured to do so.
            if ($stats) {
              \Drupal::state()
                ->set('httpbl.stat_grey', \Drupal::state()
                ->get('httpbl.stat_grey') + 1);
            }
          }
          $evaluated_status = HTTPBL_LIST_GREY;
          $evaluated = [
            'evaluated',
            $evaluated_status,
          ];
          return $evaluated;
        }
      }
      else {

        // No result from Project Honeypot, so log and then...
        $this->logTrapper
          ->trapInfo('No Honeypot profile for @ip. ("safe").', [
          '@ip' => $ip,
          'link' => self::projectLink($ip),
        ]);

        // If settings indicate we are storing results,
        if (\Drupal::state()
          ->get('httpbl.storage') > HTTPBL_DB_OFF) {

          // White-list locally - with configured offset settings (default is 3 hours).
          $this
            ->setIpLocalStatus($ip, HTTPBL_LIST_SAFE, \Drupal::state()
            ->get('httpbl.safe_offset') ?: 10800);
        }

        // Evaluated (assumed) Safe.
        $evaluated_status = HTTPBL_LIST_SAFE;
      }
      $evaluated = [
        'evaluated',
        $evaluated_status,
      ];
      return $evaluated;
    }
    elseif (!($evaluated_status == HTTPBL_LIST_SAFE)) {

      // This line will show when only blocking comment submissions.
      drupal_set_message(t('Your IP address (@ip) is restricted on this site.', [
        '@ip' => $ip,
      ]), 'error', FALSE);
    }

    // Fini!
    $evaluated = [
      'evaluated',
      $evaluated_status,
    ];
    return $evaluated;
  }

  /**
   * {@inheritdoc}
   *
   * Check if an IP is already white-listed via a session white-list challenge.
   */
  public static function visitor_whitelisted_session($ip) {
    return isset($_SESSION['httpbl_status']) && $_SESSION['httpbl_status'] == 'session_whitelisted';
  }

  /**
   * Do http:BL DNS lookup at Project Honeypot Org
   *
   * @param string $ip
   *    The IP address to be checked.
   * @param string $key
   *    The administrative access key.
   *
   * @return array $values | FALSE
   *
   * @todo Don't think anything is really capturing the response type
   *  values to store with the hosts.  Use these?
   */
  public function httpbl_dnslookup($ip, $key = NULL) {

    // Thanks to J.Wesley2 at
    // http://www.projecthoneypot.org/board/read.php?f=10&i=1&t=1
    if (!($ip = self::_httpbl_reverse_ip($ip))) {
      return FALSE;
    }

    // Make sure there is a valid access key before we proceed.
    if (!$key && !($key = \Drupal::state()
      ->get('httpbl.accesskey') ?: NULL)) {
      return FALSE;
    }
    $query = $key . '.' . $ip . '.dnsbl.httpbl.org.';
    $response = gethostbyname($query);
    if ($response == $query) {

      // if the domain does not resolve then it will be the same thing we passed to gethostbyname.
      return FALSE;
    }
    $values = array();
    $values['raw'] = $response;
    $response = explode('.', $response);
    if ($response[0] != '127') {

      // if the first octet is not 127, the response should be considered invalid
      $this->logTrapper
        ->trapWarning('DNS Lookup failed for @ip, response was @response', array(
        '@ip' => $ip,
        '@response' => $values['raw'],
      ));
      return FALSE;
    }

    // Lookup at Project Honey Pot was successful.
    $this->logTrapper
      ->trapDebug('DNS lookup results for @ip, response was @response', array(
      '@ip' => $ip,
      '@response' => $values['raw'],
    ));
    $values['last_activity'] = $response[1];
    $values['threat'] = $response[2];
    $values['type'] = $response[3];
    if ($response[3] == 0) {

      //if it's 0 then it's only a Search Engine
      $values['search_engine'] = TRUE;
    }
    if ($response[3] & 1) {

      //does it have the same bits as 1 set
      $values['suspicious'] = TRUE;
    }
    if ($response[3] & 2) {

      //does it have the same bits as 2 set
      $values['harvester'] = TRUE;
    }
    if ($response[3] & 4) {

      //does it have the same bits as 4 set
      $values['comment_spammer'] = TRUE;
    }
    return $values;
  }

  /**
   * Reverse IP octets
   *
   * @param string $ip
   * @return string
   */
  public static function _httpbl_reverse_ip($ip) {
    if (!is_numeric(str_replace('.', '', $ip))) {
      return NULL;
    }
    $ip = explode('.', $ip);
    if (count($ip) != 4) {
      return NULL;
    }
    return $ip[3] . '.' . $ip[2] . '.' . $ip[1] . '.' . $ip[0];
  }

  /**
   * {@inheritdoc}
   *
   * Get status of IP in httpbl_host table of stored hosts.
   *
   * (legacy name was "_httpbl_cache_get".)
   */
  public function getIpLocalStatus($ip) {

    // Gather all hosts with this IP.
    $hosts = HostQuery::loadHostsByIp($ip);

    // If we have some, count them.
    if (isset($hosts) && !empty($hosts)) {
      $count = count($hosts);

      // As long as there's more than one...
      while ($count > 1) {

        // Sort them in order by index.
        ksort($hosts);

        // Get that host and delete it.
        $id = key($hosts);
        $host = Host::load($id);
        $host
          ->delete();

        // Reverse sort the array and remove the last one.
        arsort($hosts);
        array_pop($hosts);

        // Rinse and repeat.
        $count--;
      }

      // Get the status of the last IP found.
      $id = key($hosts);
      $host = Host::load($id);
      $status = $host
        ->getHostStatus();
    }
    else {
      $status = NULL;
    }
    return $status;
  }

  /**
   * {@inheritdoc}
   *
   * Create and store new evaluated hosts to httpbl_host table.
   *
   * (legacy name was "_httpbl_cache_set")
   */
  public function setIpLocalStatus($ip, $status, $offset = 0) {
    $hosts = HostQuery::loadHostsByIp($ip);
    if (isset($hosts) && empty($hosts)) {
      $host = Host::create([
        'host_ip' => $ip,
        'host_status' => $status,
        'expire' => \Drupal::time()
          ->getRequestTime() + $offset,
        'source' => HTTPBL_ORIGINAL_SOURCE,
      ]);
      $host
        ->save();
      $project_link = $host
        ->projectLink();
      $source = $host
        ->getSource();

      // If configured to also ban blacklisted IPs via Drupal Core Ban module...
      if ($status == HTTPBL_LIST_BLACK && \Drupal::state()
        ->get('httpbl.storage') == HTTPBL_DB_HH_DRUPAL && \Drupal::moduleHandler()
        ->moduleExists('ban')) {

        // Ban this IP!
        $this->banManager
          ->banIp($ip);
        $this->logTrapper
          ->trapNotice('Host: new blacklisted and banned @title. Source: @source.', array(
          '@title' => $host
            ->label(),
          '@source' => $source,
          'link' => $project_link,
        ));
      }
      elseif ($status == HTTPBL_LIST_BLACK && \Drupal::state()
        ->get('httpbl.storage') == HTTPBL_DB_HH) {
        $this->logTrapper
          ->trapNotice('Host: new blacklisted @title. Source: @source.', array(
          '@title' => $host
            ->label(),
          '@source' => $source,
          'link' => $project_link,
        ));
      }
      elseif ($status == HTTPBL_LIST_GREY) {
        $this->logTrapper
          ->trapNotice('Host: new grey-listed @title. Source: @source.', array(
          '@title' => $host
            ->label(),
          '@source' => $source,
          'link' => $project_link,
        ));
      }
      elseif ($status == HTTPBL_LIST_SAFE) {

        // Most IPs should be safe, so only log this as Info.
        $this->logTrapper
          ->trapInfo('Host: new white-listed @title. Source: @source.', array(
          '@title' => $host
            ->label(),
          '@source' => $source,
          'link' => $project_link,
        ));
      }
      return;
    }
    else {
      $this->logTrapper
        ->trapError('Attempt to add host @ip, but it already exists!', [
        '@ip' => $ip,
      ]);
    }
    return FALSE;
  }

  /**
   * {@inheritdoc}
   *
   * Emergency White-list Update of Host IP.
   */
  public function drushWhitelist($ip) {

    // Set a default of 48 hours.
    $offset = 172800;

    // Take the higher of the default or the configured offset for safe hosts.
    $offsetConfig = \Drupal::state()
      ->get('httpbl.safe_offset');

    //$max <= $limit ?: $max = $limit;
    $offset >= $offsetConfig ?: ($offset = $offsetConfig);
    $hosts = HostQuery::loadHostsByIp($ip);
    if (isset($hosts) && !empty($hosts)) {
      foreach ($hosts as $host) {
        $host
          ->setHostStatus(0);
        $host
          ->setExpiry(\Drupal::time()
          ->getRequestTime() + $offset);
        $host
          ->setSource(HTTPBL_DRUSH_SOS_SOURCE);
        $host
          ->save();
      }
    }
    else {

      // Warning to identify any abuse.
      $this->logTrapper
        ->trapWarning('Drush whitelist did not find IP @ip.', [
        '@ip' => $ip,
      ]);
    }
  }

  /**
   * {@inheritdoc}
   */
  public function getHumanStatus($status) {
    switch ($status) {
      case '0':
        $human = t('White-listed');
        break;
      case '1':
        $human = t('Blacklisted');
        break;
      case '2':
        $human = t('Grey-listed');
        break;
    }
    return $human;
  }

  /**
   * Update stored status of Host IP.
   *
   * (legacy name was "_httpbl_cache_update".)
   */
  public static function updateIpLocalStatus($ip, $status, $offset = 0) {

    // Collect needed services.
    $banManager = \Drupal::service('ban.ip_manager');
    $logTrapper = \Drupal::service('httpbl.logtrapper');
    $hosts = HostQuery::loadHostsByIp($ip);
    if (isset($hosts) && !empty($hosts)) {
      foreach ($hosts as $host) {
        $host
          ->setHostStatus($status);
        $host
          ->setExpiry(\Drupal::time()
          ->getRequestTime() + $offset);
        $host
          ->setSource(HTTPBL_CHALLENGE_FAILURE);
        $host
          ->save();
      }
    }
    else {

      // Error.  Something could be broken.
      $logTrapper
        ->trapError('Cannot blacklist non-existing IP (@ip).', [
        '@ip' => $ip,
      ]);
    }

    // If blacklisted host and using "Auto-banning"...
    if ($status == HTTPBL_LIST_BLACK && \Drupal::state()
      ->get('httpbl.storage') == HTTPBL_DB_HH_DRUPAL) {

      // Check if host is already banned.
      if ($banManager
        ->isBanned($ip)) {

        // Warning.  This shouldn't be happening.
        $logTrapper
          ->trapWarning('This host (@ip) is already banned', [
          '@ip' => $ip,
        ]);

        // This message should never be seen by anyone who has really been banned.
        drupal_set_message(t('IP address ( @ip ) currently banned from this site.', array(
          '@ip' => $ip,
        )), 'error', FALSE);
      }
      else {
        $banManager
          ->banIp($ip);

        // Warning.   Most likely a white-list challenge failure.
        $logTrapper
          ->trapWarning('Host (@ip) has been banned from this site.', [
          '@ip' => $ip,
        ]);

        // Possible to see this message once after failing challenge, but never
        // again after a page refresh.
        drupal_set_message(t('Your IP address ( @ip ) has been banned from this site.', array(
          '@ip' => $ip,
        )), 'error', FALSE);
      }
    }
  }

  /**
   * Quickly make up to 255 evaluated hosts of a certain status and expire time.
   *
   * @param int    $max     The number of hosts to be generated.
   * @param int    $status  The status (0=safe, 1=blackisted, 2=grey-listed)
   * @param int    $offset  The time from now the host should expire.
   * @param string $pattern The pattern used for IP addresses.
   *
   * @internal  $count
   * ---------------------------------------------------------------------------
   *
   * Example uses:
   * Execute in devel/php to make dummy hosts.
   *
   * Also executable in drush as "drush mho".
   *
   * use Drupal\httpbl\Utility\Makehosts;
   * Makehosts::makeHosts(); // Use defaults and create 255 safe hosts that will
   * expire in 5 minutes.  Useful for testing Cron.
   *
   * use Drupal\httpbl\Utility\Makehosts;
   * Makehosts::makeHosts(50,2, 60, '129.0.1.');  Make some grey-listed and edit
   * them.
   *
   * use Drupal\httpbl\Utility\Makehosts;
   * Makehosts::makeHosts(1,0, 120, '127.0.0.' ); Make a localhost that lasts
   * 2 minutes.  Then delete it (it will come right back, from Project Honeypot!)
   *
   */
  public static function makeHosts($max = 255, $status = 0, $offset = 300, $pattern = '127.0.1.') {
    $limit = 255;
    $max <= $limit ?: ($max = $limit);
    $count = 1;
    $max = $max + 1;
    while ($count < $max) {
      $ip = $pattern . $count;
      $host = Host::create([
        'host_ip' => $ip,
        'host_status' => $status,
        'expire' => \Drupal::time()
          ->getRequestTime() + $offset,
        'source' => t(HTTPBL_DRUSH_CREATED),
      ]);
      $host
        ->save();
      $logTrapper = \Drupal::service('httpbl.logtrapper');
      $logTrapper
        ->trapDebug('@ip test host created with makeHosts().', [
        '@ip' => $ip,
      ]);
      $count++;
    }
  }

  /**
   * Quickly make up to 255 evaluated and banned hosts of a certain expire time.
   *
   * @param int    $max     The number of hosts to be generated.
   * @param int    $offset  The time from now the host should expire.
   * @param string $pattern The pattern used for IP addresses.
   *
   * @internal  $count
   * ---------------------------------------------------------------------------
   *
   * Example uses:
   * Execute in devel/php to make dummy hosts.
   *
   * Also executable in drush as "drush mbb".
   *
   * use Drupal\httpbl\Utility\Makehosts;
   * Makehosts::makeBannedHosts(); // Use defaults to create 255 blacklisted and
   * banned hosts that will expire in 5 minutes.  Useful for testing Cron.
   *
   * use Drupal\httpbl\Utility\Makehosts;
   * Makehosts::makeBannedHosts(50,60, '129.0.8.');  Make 50 blacklisted and
   * banned hosts that will last one minute.
   *
   */
  public static function makeBannedHosts($max = 255, $offset = 300, $pattern = '127.1.8.') {
    $limit = 255;
    $max <= $limit ?: ($max = $limit);
    $count = 1;
    $max = $max + 1;
    $status = 1;
    while ($count < $max) {
      $ip = $pattern . $count;
      $host = Host::create([
        'host_ip' => $ip,
        'host_status' => $status,
        'expire' => \Drupal::time()
          ->getRequestTime() + $offset,
        'source' => t(HTTPBL_DRUSH_CREATED_BANNED),
      ]);
      $host
        ->save();
      $banManager = \Drupal::service('ban.ip_manager');
      $banManager
        ->banIp($host
        ->label());
      $logTrapper = \Drupal::service('httpbl.logtrapper');
      $logTrapper
        ->trapDebug('@ip test banned host created with makeBannedHosts().', [
        '@ip' => $ip,
      ]);
      $count++;
    }
  }

  /**
   * Creates a link to Project Honey Pot IP Address Inspector.
   *
   * This function is used after a lookup, before a host entity has been
   * created, to enable an operations link in the log entry.
   *
   * @param string $ip
   *   The IP address that was looked up.
   * @param string $text
   *   The link text.
   * @return string
   *   The formatted link.
   */
  public static function projectLink($ip, $text = 'Project Honeypot') {
    $url = \Drupal\Core\Url::fromUri('http://www.projecthoneypot.org/search_ip.php?ip=' . $ip);
    $url_options = [
      'attributes' => [
        'target' => '_blank',
        'title' => t('Project Honey Pot IP Address Inspector.'),
      ],
    ];
    $url
      ->setOptions($url_options);

    // Break this line up for debugging.

    //$operations = \Drupal\Core\Link::fromTextAndUrl(t($text), $url )->toString();
    $operations = \Drupal\Core\Link::fromTextAndUrl(t($text), $url);

    // Below fails (intermittently) in core url_generator, when page_cache
    // is enabled.
    $operations = $operations
      ->toString();
    return $operations;
  }

}