$x) $o[] = json_encode((string)$k, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS) . ':' . self::canon($x); return '{' . implode(',', $o) . '}'; } return '[' . implode(',', array_map(array(__CLASS__, 'canon'), $v)) . ']'; } if ($v instanceof stdClass) { $a = (array)$v; return count($a) ? self::canon($a) : '{}'; } if ($v === null) return 'null'; if (is_bool($v)) return $v ? 'true' : 'false'; if (is_int($v)) return (string)$v; if (is_float($v)) return json_encode($v); return json_encode((string)$v, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_LINE_TERMINATORS); } public static function b64u($b) { return rtrim(strtr(base64_encode($b), '+/', '-_'), '='); } public static function state($store) { $s = $store->get(); if (!$s || empty($s['sk'])) { $kp = sodium_crypto_sign_keypair(); $pk = sodium_crypto_sign_publickey($kp); $x = self::b64u($pk); $kid = self::b64u(hash('sha256', '{"crv":"Ed25519","kty":"OKP","x":"' . $x . '"}', true)); $s = array('sk' => base64_encode(sodium_crypto_sign_secretkey($kp)), 'x' => $x, 'kid' => $kid, 'enrolled' => false, 'id' => null); $store->set($s); } return $s; } public static function sign($s, $msg) { return self::b64u(sodium_crypto_sign_detached($msg, base64_decode($s['sk']))); } public static function observer_id($site) { return 'ind:site-' . substr(preg_replace('/[^a-z0-9-]/', '-', strtolower(preg_replace('/^www\./', '', $site))), 0, 26); } // One run: enrol once (with the site binding), then read tasks and submit one signed observation per task. public static function run($site, $store, $http, $operator = null) { $s = self::state($store); $id = $s['id'] ?: ($site ? self::observer_id($site) : 'ind:cli-' . substr(strtolower($s['kid']), 0, 10)); $pub = array('kty' => 'OKP', 'crv' => 'Ed25519', 'x' => $s['x']); $out = array('observer_id' => $id, 'kid' => $s['kid'], 'enrol' => null, 'tasks' => 0, 'stored' => 0, 'errors' => array()); if (empty($s['enrolled'])) { $req = array('kind' => 'crawlcheck-observer-enrolment', 'v' => 1, 'observer_id' => $id, 'key' => $pub, 'operator' => $operator ?: ($site ? 'Site observer on ' . $site : 'CrawlCheck observer (command line)'), 'network' => $site ? 'the web host of ' . $site : 'this machine\'s network', 'software' => array('name' => 'crawlcheck-observer-wordpress', 'version' => self::VERSION), 'requested_at' => gmdate('Y-m-d\TH:i:s.000\Z')); if ($site) $req['site'] = $site; $r = $http('POST', self::BASE . '/api/observe/enroll', array('request' => $req, 'sig' => self::sign($s, self::ENROL . self::canon($req))), array()); $out['enrol'] = $r; if (!$r || empty($r['json']['ok'])) { $out['errors'][] = 'enrol: ' . ($r ? substr($r['body'], 0, 200) : 'no response'); return $out; } $s['enrolled'] = true; $s['id'] = $id; $store->set($s); } $t = gmdate('Y-m-d\TH:i:s.000\Z'); $tk = $http('GET', self::BASE . '/api/observe/tasks', null, array('x-cc-observer-id' => $id, 'x-cc-observer-time' => $t, 'x-cc-observer-sig' => self::sign($s, self::TASKS . $id . "\n" . $t))); if (!$tk || $tk['status'] !== 200) { $out['errors'][] = 'tasks: ' . ($tk ? $tk['status'] . ' ' . substr($tk['body'], 0, 200) : 'no response'); return $out; } $plan = $tk['json']; foreach (array_slice(isset($plan['tasks']) ? $plan['tasks'] : array(), 0, 6) as $task) { $out['tasks']++; $fetches = array(); foreach ($task['identities'] as $ident) { $t0 = microtime(true); $row = array('identity' => $ident['id'], 'request' => array('headers' => (object)$ident['headers']), 'started_at' => gmdate('Y-m-d\TH:i:s.000\Z'), 'ms' => 0, 'status' => null, 'url_final' => null, 'headers' => new stdClass(), 'bytes' => 0, 'sha256' => null, 'error' => null); $g = $http('FETCH', $task['url'], null, $ident['headers']); if ($g && $g['status']) { $row['status'] = $g['status']; $row['url_final'] = $g['final'] ?: $task['url']; $row['bytes'] = strlen($g['body']); $row['sha256'] = hash('sha256', $g['body']); } else $row['error'] = $g && !empty($g['error']) ? substr($g['error'], 0, 160) : 'no response'; $row['ms'] = (int)round((microtime(true) - $t0) * 1000); $fetches[] = $row; } $env = array('v' => 1, 'kind' => 'crawlcheck-observation', 'observer_id' => $id, 'software' => array('name' => 'crawlcheck-observer-wordpress', 'version' => self::VERSION), 'run' => array('run_id' => 'wp-' . time()), 'vantage' => array('provider' => 'web host of ' . $site), 'clock' => array('observed_at' => gmdate('Y-m-d\TH:i:s.000\Z'), 'skew_ms' => 0), 'profile_sha256' => isset($task['profile_sha256']) ? $task['profile_sha256'] : $plan['profile_sha256'], 'task' => array('task_id' => $task['task_id'], 'domain' => $task['domain'], 'url' => $task['url']), 'fetches' => $fetches, 'key' => $pub); $pr = $http('POST', self::BASE . '/api/observe/signed', array('envelope' => $env, 'sig' => self::sign($s, self::SIG . self::canon($env)), 'bodies' => new stdClass()), array()); if ($pr && !empty($pr['json']['ok'])) $out['stored']++; else $out['errors'][] = $task['domain'] . ': ' . ($pr ? $pr['status'] . ' ' . substr($pr['body'], 0, 200) : 'no response'); } return $out; } } if (defined('ABSPATH')) { final class CrawlCheck_Observer_WP { public static function store() { return new class { function get() { return get_option(CrawlCheck_Observer::OPT, null); } function set($v) { update_option(CrawlCheck_Observer::OPT, $v, false); } }; } public static function http($method, $url, $json, $headers) { $args = array('timeout' => $method === 'FETCH' ? 12 : 20, 'redirection' => 5, 'headers' => $headers ?: array(), 'cookies' => array()); if ($method === 'POST') { $args['headers']['content-type'] = 'application/json'; $args['body'] = CrawlCheck_Observer::canon($json); } $r = $method === 'POST' ? wp_remote_post($url, $args) : wp_remote_get($url, $args); if (is_wp_error($r)) return array('status' => 0, 'body' => '', 'json' => null, 'error' => $r->get_error_message(), 'final' => null); $body = (string)wp_remote_retrieve_body($r); $final = $url; if (isset($r['http_response']) && is_object($r['http_response']) && method_exists($r['http_response'], 'get_response_object')) { $ro = $r['http_response']->get_response_object(); if (!empty($ro->url)) $final = $ro->url; } return array('status' => (int)wp_remote_retrieve_response_code($r), 'body' => $body, 'json' => json_decode($body, true), 'final' => $final); } public static function site() { return preg_replace('/^www\./', '', (string)wp_parse_url(home_url(), PHP_URL_HOST)); } public static function tick() { $o = CrawlCheck_Observer::run(self::site(), self::store(), array(__CLASS__, 'http')); update_option('crawlcheck_observer_last', array('at' => gmdate('c'), 'result' => $o), false); } } // /.well-known/crawlcheck-observer.txt: proves this site runs the observer with this key. add_action('init', function () { $p = isset($_SERVER['REQUEST_URI']) ? strtok($_SERVER['REQUEST_URI'], '?') : ''; if ($p !== '/.well-known/crawlcheck-observer.txt') return; $s = CrawlCheck_Observer::state(CrawlCheck_Observer_WP::store()); nocache_headers(); header('Content-Type: text/plain; charset=utf-8'); echo "# CrawlCheck observer on this site. https://crawlcheck.io/docs/observer-protocol\nkid=" . $s['kid'] . "\n"; exit; }, 0); add_action('crawlcheck_observer_tick', array('CrawlCheck_Observer_WP', 'tick')); add_action('init', function () { if (!wp_next_scheduled('crawlcheck_observer_tick')) wp_schedule_event(time() + 300, 'hourly', 'crawlcheck_observer_tick'); }); }