$payload * @return array{ok:bool,status:int,body:array|null,error:string|null,retryable:bool} */ public function post_json( string $path, array $payload, bool $gzip = false ): array { $json = wp_json_encode( $payload ); if ( ! is_string( $json ) ) { return $this->failure( 0, 'json_encode_failed', false ); // poison batch: only the cloud's 4xx path may dead-letter } $body = $json; $headers = array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'operanse-agent/' . OPERANSE_AGENT_VERSION . ' (WordPress)', ); if ( $gzip && function_exists( 'gzencode' ) ) { $encoded = gzencode( $json, 6 ); if ( is_string( $encoded ) ) { $body = $encoded; $headers['Content-Encoding'] = 'gzip'; } } $headers['Authorization'] = Signer::header( $this->credentials->secret(), Signer::DIRECTION_TO_CLOUD, 'POST', $path, $this->credentials->site_id(), $this->credentials->key_id(), $body ); return $this->send( $path, $body, $headers ); } /** * The part of a request path the signature covers: everything before the query string. * * The cloud verifies against `req.url.split('?')[0]` (`agent-auth.ts`), so an agent that signed the whole * thing would be rejected the moment any signed GET carried a parameter — which is exactly what happened the * first time one did (`/v1/agent/update?current=…`, ADR-036). The two sides agree here rather than by * coincidence, and the rule is the same one the POST paths have always satisfied by never having a query. */ public static function signed_path( string $path ): string { $q = strpos( $path, '?' ); return $q === false ? $path : substr( $path, 0, $q ); } /** * Signed GET with an empty body (Mode C stream open, ADR-018). The signature covers sha256("") like every * body-less request; `$timeout` is the long-poll hold plus slack. Returns the raw body (SSE text) on success. * * @return array{ok:bool,status:int,raw:string,error:string|null,retryable:bool} */ public function get_signed( string $path, int $timeout ): array { $url = $this->settings->api_url() . $path; if ( ! str_starts_with( $url, 'https://' ) && ! $this->settings->allows_insecure_http() ) { return array( 'ok' => false, 'status' => 0, 'raw' => '', 'error' => 'insecure_api_url', 'retryable' => true ); } $headers = array( 'Accept' => 'text/event-stream', 'User-Agent' => 'operanse-agent/' . OPERANSE_AGENT_VERSION . ' (WordPress; stream)', 'Authorization' => Signer::header( $this->credentials->secret(), Signer::DIRECTION_TO_CLOUD, 'GET', self::signed_path( $path ), $this->credentials->site_id(), $this->credentials->key_id(), '' ), ); $response = wp_remote_get( $url, array( 'timeout' => max( 5, $timeout ), 'redirection' => 0, 'blocking' => true, 'headers' => $headers, 'sslverify' => true, 'limit_response_size' => self::MAX_RESPONSE_BYTES ) ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'status' => 0, 'raw' => '', 'error' => $response->get_error_code() . ': ' . $response->get_error_message(), 'retryable' => true ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); if ( $status >= 200 && $status < 300 ) { return array( 'ok' => true, 'status' => $status, 'raw' => $raw, 'error' => null, 'retryable' => false ); } return array( 'ok' => false, 'status' => $status, 'raw' => '', 'error' => 'http_' . $status, 'retryable' => $status >= 500 || $status === 429 || $status === 408 || $status === 409 ); } /** * Signed GET that streams the response body to a file (ADR-036, the plugin package). * * A separate method rather than a flag on `get_signed()` because the difference is not a parameter: this one * must never hold a multi-megabyte body in memory on a customer's server, and its caller wants a path rather * than a string. Nothing is parsed — the caller verifies the digest before anything reads the file. * * @return array{ok:bool,status:int,error:string|null} */ public function download_signed( string $path, string $dest, int $timeout = 60 ): array { $url = $this->settings->api_url() . $path; if ( ! str_starts_with( $url, 'https://' ) && ! $this->settings->allows_insecure_http() ) { return array( 'ok' => false, 'status' => 0, 'error' => 'insecure_api_url' ); } $headers = array( 'Accept' => 'application/zip', 'User-Agent' => 'operanse-agent/' . OPERANSE_AGENT_VERSION . ' (WordPress; update)', 'Authorization' => Signer::header( $this->credentials->secret(), Signer::DIRECTION_TO_CLOUD, 'GET', self::signed_path( $path ), $this->credentials->site_id(), $this->credentials->key_id(), '' ), ); $response = wp_remote_get( $url, array( 'timeout' => max( 15, $timeout ), 'redirection' => 0, // the package comes from this API or not at all 'blocking' => true, 'headers' => $headers, 'sslverify' => true, 'stream' => true, 'filename' => $dest, // The same cap `Updater` enforces on the file afterwards, applied while the bytes arrive. 'limit_response_size' => self::MAX_PACKAGE_BYTES, ) ); if ( is_wp_error( $response ) ) { return array( 'ok' => false, 'status' => 0, 'error' => $response->get_error_code() . ': ' . $response->get_error_message() ); } $status = (int) wp_remote_retrieve_response_code( $response ); if ( $status < 200 || $status >= 300 ) { return array( 'ok' => false, 'status' => $status, 'error' => 'http_' . $status ); } return array( 'ok' => true, 'status' => $status, 'error' => null ); } /** * Unsigned pairing request (the pairing token is the credential). * * @param array $payload * @return array{ok:bool,status:int,body:array|null,error:string|null,retryable:bool} */ public function post_unsigned( string $path, array $payload ): array { $json = wp_json_encode( $payload ); if ( ! is_string( $json ) ) { return $this->failure( 0, 'json_encode_failed', false ); } return $this->send( $path, $json, array( 'Content-Type' => 'application/json', 'Accept' => 'application/json', 'User-Agent' => 'operanse-agent/' . OPERANSE_AGENT_VERSION ) ); } /** * @param array $headers * @return array{ok:bool,status:int,body:array|null,error:string|null,retryable:bool} */ private function send( string $path, string $body, array $headers ): array { $url = $this->settings->api_url() . $path; if ( ! str_starts_with( $url, 'https://' ) && ! $this->settings->allows_insecure_http() ) { // Local misconfiguration, not a rejection by the cloud: retryable so spooled data is kept (with backoff). return $this->failure( 0, 'insecure_api_url', true ); } $response = wp_remote_post( $url, array( 'timeout' => self::TIMEOUT_SECONDS, 'redirection' => 0, 'blocking' => true, 'headers' => $headers, 'body' => $body, 'sslverify' => true, 'limit_response_size' => self::MAX_RESPONSE_BYTES, ) ); if ( is_wp_error( $response ) ) { return $this->failure( 0, $response->get_error_code() . ': ' . $response->get_error_message(), true ); } $status = (int) wp_remote_retrieve_response_code( $response ); $raw = (string) wp_remote_retrieve_body( $response ); $parsed = json_decode( $raw, true ); $data = is_array( $parsed ) ? $parsed : null; if ( $status >= 200 && $status < 300 ) { return array( 'ok' => true, 'status' => $status, 'body' => $data, 'error' => null, 'retryable' => false ); } $retryable = $status >= 500 || $status === 429 || $status === 408; $error = is_array( $data ) && isset( $data['error'] ) ? (string) $data['error'] : 'http_' . $status; return array( 'ok' => false, 'status' => $status, 'body' => $data, 'error' => $error, 'retryable' => $retryable ); } /** @return array{ok:bool,status:int,body:array|null,error:string|null,retryable:bool} */ private function failure( int $status, string $error, bool $retryable ): array { return array( 'ok' => false, 'status' => $status, 'body' => null, 'error' => $error, 'retryable' => $retryable ); } }