diff --git a/src/helper/Site_Letsencrypt.php b/src/helper/Site_Letsencrypt.php index 9d52b019..4189ca4e 100644 --- a/src/helper/Site_Letsencrypt.php +++ b/src/helper/Site_Letsencrypt.php @@ -375,15 +375,16 @@ public function authorize( Array $domains, $wildcard = false, $preferred_challen $token = $authorizationChallenge->toArray()['token']; $payload = $authorizationChallenge->toArray()['payload']; - $fs = new \Symfony\Component\Filesystem\Filesystem(); - $fs->copy( SITE_TEMPLATE_ROOT . '/vhost.d_default_letsencrypt.mustache', EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/default' ); - $challange_dir = EE_ROOT_DIR . '/services/nginx-proxy/html/.well-known/acme-challenge'; - if ( ! $fs->exists( $challange_dir ) ) { - $fs->mkdir( $challange_dir ); - } + $fs = new \Symfony\Component\Filesystem\Filesystem(); + $rule_file = EE_ROOT_DIR . '/services/nginx-proxy/vhost.d/default'; + $fs->copy( SITE_TEMPLATE_ROOT . '/vhost.d_default_letsencrypt.mustache', $rule_file ); + $this->chmod_unless_link( $rule_file, 0644 ); + $challange_dir = $this->prepare_http_challenge_dir( true ); $challange_file = $challange_dir . '/' . $token; \EE::debug( 'Creating challange file ' . $challange_file ); $fs->dumpFile( $challange_file, $payload ); + // dumpFile() keeps the mode of a file it replaces, or follows the umask for a new one. + $this->chmod_unless_link( $challange_file, 0644 ); reload_global_nginx_proxy(); } } @@ -476,6 +477,12 @@ public function check( Array $domains, $wildcard = false, $preferred_challenge = $solver = empty ( get_config_value( 'cloudflare-api-key' ) ) ? new SimpleDnsSolver( null, new ConsoleOutput() ) : new SimpleDnsCloudflareSolver( null, new ConsoleOutput() ); } else { $solver = new SimpleHttpSolver(); + // A pending order is validated against the token file an earlier authorize() wrote, so fix up what it left behind. + try { + $this->prepare_http_challenge_dir( false ); + } catch ( \Exception $e ) { + \EE::debug( 'Unable to repair the HTTP-01 challenge dir: ' . $e->getMessage() ); + } } $validator = new ChainValidator( [ @@ -1022,6 +1029,53 @@ private function getOrCreateDistinguishedName( $domain, array $alternativeNames, return $distinguishedName; } + /** + * Create the HTTP-01 challenge dir, or repair one a strict umask left 0700 (it outlives a failed verify), so nginx-proxy can read it. + * Stale token files are kept: `ssl-verify` retries a pending order against the token on disk. + * + * @param bool $create Create the dir when it is missing. + * + * @throws \Symfony\Component\Filesystem\Exception\IOException + * @return string The acme-challenge dir. + */ + private function prepare_http_challenge_dir( $create ) { + $fs = new \Symfony\Component\Filesystem\Filesystem(); + $dir = EE_ROOT_DIR . '/services/nginx-proxy/html'; + clearstatcache(); + foreach ( [ '.well-known', 'acme-challenge' ] as $part ) { + $dir .= '/' . $part; + // Only EE creates these, as real dirs; never chmod through a link planted here. + if ( is_link( $dir ) || is_file( $dir ) ) { + $fs->remove( $dir ); + } + if ( ! is_dir( $dir ) ) { + if ( ! $create ) { + return $dir; + } + $fs->mkdir( $dir, 0755 ); + } + $fs->chmod( $dir, 0755 ); + } + + foreach ( scandir( $dir ) as $entry ) { + if ( '.' !== $entry && '..' !== $entry ) { + $this->chmod_unless_link( $dir . '/' . $entry, 0644 ); + } + } + + return $dir; + } + + /** + * Set a regular file's mode, leaving symlinks (and their targets) alone. + */ + private function chmod_unless_link( $file, $mode ) { + clearstatcache( true, $file ); + if ( ! is_link( $file ) && is_file( $file ) ) { + ( new \Symfony\Component\Filesystem\Filesystem() )->chmod( $file, $mode ); + } + } + /** * Cleanup created challenge files and specific rule sets for it. */ diff --git a/src/helper/class-ee-site.php b/src/helper/class-ee-site.php index 0427931d..505a395c 100644 --- a/src/helper/class-ee-site.php +++ b/src/helper/class-ee-site.php @@ -1911,7 +1911,22 @@ protected function check_www_or_non_www_domain( $site_url, $site_path, $site_con $successful = false; $extra_path = str_replace( '/var/www/htdocs', '', $site_container_path ); $file_path = $site_path . '/app/htdocs' . $extra_path . '/check.html'; - file_put_contents( $file_path, $random_string ); + + // The site can write here: drop whatever is at the path so a planted symlink isn't followed, then create the file exclusively. + if ( is_link( $file_path ) || is_file( $file_path ) ) { + unlink( $file_path ); + } + // The site's nginx has to read the file whatever the umask is. + $old_umask = umask( 0022 ); + $handle = @fopen( $file_path, 'x' ); + umask( $old_umask ); + if ( false === $handle ) { + EE::debug( "Unable to create $file_path" ); + + return false; + } + fwrite( $handle, $random_string ); + fclose( $handle ); if ( 0 === strpos( $site_url, 'www.' ) ) { $site_url = ltrim( $site_url, 'www.' );