Skip to content

Windows SSL #138

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 26 commits into from
Mar 5, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 8 additions & 8 deletions .github/workflows/qit-environment-test-linux.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,11 @@ on:
workflow_dispatch:

jobs:
environment_tests:
runs-on: ubuntu-20.04
env:
NO_COLOR: 1
QIT_DISABLE_ONBOARDING: yes
steps:
- name: Checkout code
uses: actions/checkout@v4
environment_tests:
runs-on: ubuntu-20.04
env:
NO_COLOR: 1
QIT_DISABLE_ONBOARDING: yes
steps:
- name: Checkout code
uses: actions/checkout@v4
78 changes: 78 additions & 0 deletions .github/workflows/qit-windows.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
name: QIT Windows
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice, thanks for adding in this test workflow!


on:
push:
branches:
- trunk
# Manually
workflow_dispatch:

jobs:
qit_windows:
runs-on: windows-latest
strategy:
matrix:
php: [ 7.2, 7.3, 7.4, 8.0, 8.1, 8.2, 8.3 ]
env:
NO_COLOR: 1
QIT_DISABLE_ONBOARDING: yes
steps:
- name: Checkout code
uses: actions/checkout@v4

- name: Set up PHP ${{ matrix.php }}
uses: shivammathur/setup-php@v2
with:
php-version: ${{ matrix.php }}
extensions: curl, zip
coverage: none

- name: Composer install
working-directory: src
run: composer install

- name: Enable dev mode
working-directory: src
run: php qit-cli.php dev

- name: Run SSL connection without CA file fallback Test
working-directory: src
env:
OPENSSL_CONF: ''
run: |
php -d openssl.cafile='' -d curl.cainfo='' qit-cli.php sync -vvv
if ($LASTEXITCODE -ne 0) {
Write-Host "Test passed: SSL connection failed as expected"
$LASTEXITCODE = 0
} else {
Write-Host "Test failed: SSL connection did not fail as expected"
exit 1
}

- name: Run SSL connection with CA file fallback Test (Cache miss)
working-directory: src
env:
QIT_WINDOWS_DOWNLOAD_CA: yes
OPENSSL_CONF: ''
run: |
php -d openssl.cafile='' -d curl.cainfo='' qit-cli.php sync -vvv
if ($LASTEXITCODE -eq 0) {
Write-Host "Test passed: SSL connection succeeded"
} else {
Write-Host "Test failed: SSL connection did not succeed"
exit 1
}

- name: Run SSL connection with CA file fallback Test (Cache hit)
working-directory: src
env:
QIT_WINDOWS_DOWNLOAD_CA: yes
OPENSSL_CONF: ''
run: |
php -d openssl.cafile='' -d curl.cainfo='' qit-cli.php sync -vvv
if ($LASTEXITCODE -eq 0) {
Write-Host "Test passed: SSL connection succeeded"
} else {
Write-Host "Test failed: SSL connection did not succeed"
exit 1
}
Binary file modified qit
Binary file not shown.
127 changes: 127 additions & 0 deletions src/src/RequestBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@

use QIT_CLI\Exceptions\DoingAutocompleteException;
use QIT_CLI\Exceptions\NetworkErrorException;
use QIT_CLI\IO\Input;
use QIT_CLI\IO\Output;
use Symfony\Component\Console\Helper\QuestionHelper;
use Symfony\Component\Console\Question\ConfirmationQuestion;

class RequestBuilder {
/** @var string $url */
Expand Down Expand Up @@ -34,6 +37,11 @@ class RequestBuilder {
/** @var int */
protected $timeout_in_seconds = 15;

/**
* @var bool Whether we asked about CA file on this request.
*/
protected static $asked_ca_file_override = false;

public function __construct( string $url = '' ) {
$this->url = $url;
}
Expand Down Expand Up @@ -160,6 +168,8 @@ public function request(): string {
CURLOPT_HEADER => 1,
];

$this->maybe_set_certificate_authority_file( $curl_parameters );

if ( App::make( Output::class )->isVeryVerbose() ) {
$curl_parameters[ CURLOPT_VERBOSE ] = true;
}
Expand Down Expand Up @@ -265,6 +275,16 @@ public function request(): string {
goto retry_request; // phpcs:ignore Generic.PHP.DiscourageGoto.Found
}
} else {
// Is it an SSL error?
foreach ( [ 'ssl', 'certificate', 'issuer' ] as $keyword ) {
if ( stripos( $error_message, $keyword ) !== false ) {
$downloaded = $this->maybe_download_certificate_authority_file();
if ( $downloaded ) {
goto retry_request; // phpcs:ignore Generic.PHP.DiscourageGoto.Found
}
break;
}
}
if ( $this->retry > 0 ) {
$this->retry --;
App::make( Output::class )->writeln( sprintf( '<comment>Request failed... Retrying (HTTP Status Code %s)</comment>', $response_status_code ) );
Expand All @@ -289,6 +309,113 @@ public function request(): string {
return $body;
}

/**
* @param array<int,scalar> $curl_parameters
*
* @return void
*/
protected function maybe_set_certificate_authority_file( array &$curl_parameters ) {
// Early bail: We only do this for Windows.
if ( ! is_windows() ) {
return;
}

$cached_ca_filepath = App::make( Environment::class )->get_cache()->get( 'ca_filepath' );

// Cache hit.
if ( $cached_ca_filepath !== null && file_exists( $cached_ca_filepath ) ) {
$curl_parameters[ CURLOPT_CAINFO ] = $cached_ca_filepath;
}
}

/**
* @return bool Whether it downloaded the CA file or not.
*/
protected function maybe_download_certificate_authority_file(): bool {
$output = App::make( Output::class );
// Early bail: We only do this for Windows.
if ( ! is_windows() ) {
if ( $output->isVerbose() ) {
$output->writeln( 'Skipping certificate authority file check. Not running on Windows.' );
}

return false;
}

if ( $output->isVerbose() ) {
$output->writeln( 'Checking if we need to download the certificate authority file...' );
}

$cached_ca_filepath = App::make( Environment::class )->get_cache()->get( 'ca_filepath' );

// Cache hit.
if ( $cached_ca_filepath !== null && file_exists( $cached_ca_filepath ) ) {
return false;
}

if ( $output->isVerbose() ) {
$output->writeln( 'No cached certificate authority file found.' );
}

if ( self::$asked_ca_file_override ) {
if ( $output->isVerbose() ) {
$output->writeln( 'Skipping certificate authority file check. Already asked.' );
}

return false;
}

self::$asked_ca_file_override = true;

// Ask the user if he wants us to solve it for them.
$input = App::make( Input::class );

$helper = App::make( QuestionHelper::class );
$question = new ConfirmationQuestion( "A QIT network request failed due to an SSL certificate issue on Windows. Would you like to download a CA file, used exclusively for QIT requests, to potentially fix this?\n Please answer [y/n]: ", false );

if ( getenv( 'QIT_WINDOWS_DOWNLOAD_CA' ) !== 'yes' && ( ! $input->isInteractive() || ! $helper->ask( $input, $output, $question ) ) ) {
if ( $output->isVerbose() ) {
$output->writeln( 'Skipping certificate authority file download.' );
}

return false;
}

if ( $output->isVerbose() ) {
$output->writeln( 'Downloading certificate authority file...' );
}

// Download it to QIT Config Dir and save it in the cache.
$local_ca_file = Config::get_qit_dir() . 'cacert.pem';

if ( ! file_exists( $local_ca_file ) ) {
$remote_ca_file_contents = @file_get_contents( 'http://curl.se/ca/cacert.pem' );

if ( empty( $remote_ca_file_contents ) ) {
$output->writeln( "<error>Could not download the certificate authority file. Please download it manually from http://curl.se/ca/cacert.pem and place it in $local_ca_file</error>" );

return false;
}

if ( ! file_put_contents( $local_ca_file, $remote_ca_file_contents ) ) {
$output->writeln( "<error>Could not write the certificate authority file. Please download it manually from http://curl.se/ca/cacert.pem and place it in $local_ca_file<error>" );

return false;
}
clearstatcache( true, $local_ca_file );
}

if ( $output->isVerbose() ) {
$output->writeln( 'Certificate authority file downloaded and saved.' );
}

$year_in_seconds = 60 * 60 * 24 * 365;

App::make( Environment::class )->get_cache()->set( 'ca_filepath', $local_ca_file, $year_in_seconds );

return true;
}

protected function wait_after_429( string $headers, int $max_wait = 60 ): int {
$retry_after = null;

Expand Down