0, 'received' => 0, 'seconds' => 0.0]; /** Milliseconds between calls (pace()), and when the last request ended. */ private int $pause_ms = 0; private float $last_end = 0.0; public function __construct(ConnectionRepository $connections) { $this->connections = $connections; } /** * The last call's request and answer sizes on the wire (compressed) and how long it took. A request the other * site's server refused has its size here too. * * @return array{sent: int, received: int, seconds: float} */ public function last_call(): array { return $this->last_call; } /** * Pause between requests (Settings → Performance) for a job run from the screens: they pause between their * requests, and this pauses between the calls one of their requests makes. */ public function pace(int $ms): void { $this->pause_ms = max(0, $ms); } /** @return array{meta: array, payload: string} */ public function call(array $connection, string $op, string $job = '', array $meta = [], string $payload = '', int $timeout = 30): array { $url = rtrim((string) $connection['site_url'], '/'); if (stripos($url, 'http://') === 0 && empty($connection['allow_insecure'])) { throw new RelayError( 'connection.insecure', /* translators: %s: site URL */ esc_html(sprintf(__('%s uses plain http, so its traffic is not encrypted.', 'siterelay'), $url)), esc_html__('Use the site\'s https address, or tick "Allow insecure connection" for this connection (for example a local test site).', 'siterelay') ); } $body = Frame::encode($meta, $payload); $wait = $this->pause_ms > 0 && $this->last_end > 0.0 ? $this->last_end + $this->pause_ms / 1000 - microtime(true) : 0.0; if ($wait > 0) { usleep((int) ($wait * 1000000)); } $started = microtime(true); $this->last_call = ['sent' => strlen($body), 'received' => 0, 'seconds' => 0.0]; $styles = in_array($connection['endpoint'] ?? '', self::ENDPOINTS, true) ? [$connection['endpoint']] : self::ENDPOINTS; $last = null; foreach ($styles as $style) { $result = $this->send($connection, self::endpoint($url, $style), $op, $job, $body, $timeout); if ($result !== null) { $this->last_call['seconds'] = microtime(true) - $started; if (($connection['endpoint'] ?? '') !== $style && !empty($connection['id'])) { $this->connections->remember((string) $connection['id'], ['endpoint' => $style]); } return $result; } $last = $style; } $status = $this->last_status; throw new RelayError( 'remote.not_found', $status > 0 /* translators: 1: site URL, 2: HTTP status code */ ? esc_html(sprintf(__('SiteRelay could not reach the SiteRelay plugin on %1$s (its server answered HTTP %2$d).', 'siterelay'), $url, $status)) /* translators: %s: site URL */ : esc_html(sprintf(__('SiteRelay could not reach the SiteRelay plugin on %s.', 'siterelay'), $url)), esc_html__('Check that SiteRelay is installed and active there, and that the URL is right. If it is, a firewall or security plugin on that server may be blocking SiteRelay\'s requests.', 'siterelay'), false, ['tried' => $last, 'status' => $status] // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- context is data returned as JSON; the message is escaped ); } /** * Every value of a response header: WordPress gives a list when the header came more than once. * * @param array|\WP_Error $response * @return string[] */ private static function header_values($response, string $name): array { return array_values(array_filter(array_map('strval', (array) wp_remote_retrieve_header($response, $name)), static fn (string $v): bool => $v !== '')); } /** * The other site's server, or a firewall in front of it, refused a request before SiteRelay there saw it: as SiteRelay * answered every request before, that is usually a size limit, and smaller pieces get through. */ public static function refused_by_server(RelayError $e): bool { return in_array($e->error_code(), ['remote.too_large', 'remote.not_found'], true); } public static function endpoint(string $url, string $style): string { if ($style === 'query') { return $url . '/?rest_route=/relay/v1/remote'; } if ($style === 'ajax') { return $url . '/wp-admin/admin-ajax.php?action=relay_remote'; } return $url . '/wp-json/relay/v1/remote'; } /** null when this endpoint isn't Relay's (try the next); throws for a real answer that is an error. */ private function send(array $connection, string $endpoint, string $op, string $job, string $body, int $timeout): ?array { $this->last_status = 0; $secret = $this->connections->secret($connection); $timestamp = time(); $nonce = bin2hex(random_bytes(16)); $headers = [ 'Content-Type' => 'application/octet-stream', 'X-Relay-Key' => (string) $connection['key_id'], 'X-Relay-Op' => $op, 'X-Relay-Job' => $job, 'X-Relay-Timestamp' => (string) $timestamp, 'X-Relay-Nonce' => $nonce, 'X-Relay-Signature' => Signer::request($secret, $op, $job, (string) $connection['key_id'], $timestamp, $nonce, $body), ]; $basic = $this->connections->basic_auth($connection); if ($basic !== null) { $headers['Authorization'] = 'Basic ' . base64_encode($basic['user'] . ':' . $basic['pass']); } $response = wp_remote_post($endpoint, [ 'body' => $body, 'headers' => $headers, 'timeout' => $timeout, 'redirection' => 0, 'sslverify' => empty($connection['allow_insecure']), 'user-agent' => 'Relay/' . RELAY_VERSION . '; ' . home_url(), ]); $this->last_end = microtime(true); if (is_wp_error($response)) { throw new RelayError( 'remote.unreachable', /* translators: %s: error message from the HTTP library */ esc_html(sprintf(__('Could not reach the other site: %s', 'siterelay'), $response->get_error_message())), esc_html__('Check the URL and your connection. SiteRelay retries a few times on its own.', 'siterelay'), true ); } $status = (int) wp_remote_retrieve_response_code($response); $raw = Transport::unwrap((string) wp_remote_retrieve_body($response)); // the frame, out of the JSON it came in // A proxy may repeat the header: the answer is Relay's when one of its values signs it. $signatures = self::header_values($response, 'x-relay-signature'); $expected = $signatures !== [] ? Signer::response($secret, $nonce, $status, $raw) : ''; if (array_filter($signatures, static fn (string $s): bool => hash_equals($expected, $s)) !== []) { $this->last_call['received'] = strlen($raw); $frame = Frame::decode($raw); if ($status >= 400 || isset($frame['meta']['error'])) { throw self::remote_error($frame['meta']['error'] ?? [], $status); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the other site's error answer; remote_error() escapes its message and hint } return $frame; } // Unsigned: either not Relay at all (404 page, blocked REST API) or Relay refusing an unauthenticated request. $frame = null; try { $frame = Frame::decode($raw, 1048576); } catch (RelayError $e) { $frame = null; } if ($frame !== null && isset($frame['meta']['error']['code'])) { throw self::remote_error($frame['meta']['error'], $status); // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- the other site's error answer; remote_error() escapes its message and hint } if ($status === 413) { // The same limit applies at every address SiteRelay knows, so this is final for this request. throw new RelayError( 'remote.too_large', /* translators: %s: size of the request, e.g. "1 MB" */ esc_html(sprintf(__('The other site\'s server refused a request of %s as too large (HTTP 413).', 'siterelay'), size_format(strlen($body)))), esc_html__('SiteRelay sends smaller pieces by itself. If it keeps happening, raise that server\'s request size limit (in nginx: client_max_body_size, in Apache: LimitRequestBody), or lower Request size under Settings, Performance, on this site.', 'siterelay'), false, ['status' => 413, 'bytes' => strlen($body)] // phpcs:ignore WordPress.Security.EscapeOutput.ExceptionNotEscaped -- context is data returned as JSON; the message is escaped ); } if (in_array($status, [301, 302, 303, 307, 308], true)) { throw new RelayError('remote.redirect', esc_html__('The other site redirected the request.', 'siterelay'), esc_html__('Use the site\'s final address (for example https:// and with or without www) in the connection.', 'siterelay')); } if ($status === 401 && $basic === null && stripos(implode(', ', self::header_values($response, 'www-authenticate')), 'basic') !== false) { throw new RelayError('remote.basic_auth', esc_html__('The other site is protected by a username and password (HTTP basic authentication).', 'siterelay'), esc_html__('Enter that username and password under Pull or Push: choose the connection, tick the username and password box and connect. WP-CLI then uses the saved connection.', 'siterelay')); } if ($status >= 500) { /* translators: %d: HTTP status code */ throw new RelayError('remote.server_error', esc_html(sprintf(__('The other site had a server error (HTTP %d).', 'siterelay'), $status)), esc_html__('SiteRelay retries a few times. If it keeps failing, check the other site\'s PHP error log.', 'siterelay'), true); } $this->last_status = $status; return null; } private static function remote_error(array $error, int $status): RelayError { $code = is_string($error['code'] ?? null) ? $error['code'] : 'remote.error'; $message = is_string($error['message'] ?? null) && $error['message'] !== '' ? $error['message'] /* translators: %d: HTTP status code */ : sprintf(__('The other site refused the request (HTTP %d).', 'siterelay'), $status); return new RelayError($code, esc_html($message), esc_html(is_string($error['hint'] ?? null) ? $error['hint'] : ''), !empty($error['retryable']) || $status >= 500, ['remote' => true, 'status' => $status]); } }