libresign/lib/Service/Install/InstallService.php
Vitor Mattos dec3e8678b
fix: sign setup when build
Signed-off-by: Vitor Mattos <vitor@php.rio>
2024-07-01 19:51:05 -03:00

721 lines
23 KiB
PHP

<?php
declare(strict_types=1);
/**
* SPDX-FileCopyrightText: 2020-2024 LibreCode coop and contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
namespace OCA\Libresign\Service\Install;
use InvalidArgumentException;
use OC;
use OC\Archive\TAR;
use OC\Archive\ZIP;
use OC\Memcache\NullCache;
use OCA\Libresign\AppInfo\Application;
use OCA\Libresign\Exception\LibresignException;
use OCA\Libresign\Handler\CertificateEngine\AEngineHandler;
use OCA\Libresign\Handler\CertificateEngine\CfsslHandler;
use OCA\Libresign\Handler\CertificateEngine\Handler as CertificateEngineHandler;
use OCA\Libresign\Handler\CertificateEngine\IEngineHandler;
use OCA\Libresign\Handler\JSignPdfHandler;
use OCP\AppFramework\Services\IAppConfig;
use OCP\Files\AppData\IAppDataFactory;
use OCP\Files\IAppData;
use OCP\Files\IRootFolder;
use OCP\Files\NotFoundException;
use OCP\Files\NotPermittedException;
use OCP\Files\SimpleFS\ISimpleFile;
use OCP\Files\SimpleFS\ISimpleFolder;
use OCP\Http\Client\IClientService;
use OCP\ICache;
use OCP\ICacheFactory;
use OCP\IConfig;
use Psr\Log\LoggerInterface;
use RuntimeException;
use Symfony\Component\Console\Helper\ProgressBar;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Process;
class InstallService {
public const JAVA_VERSION = 'openjdk version "21.0.2" 2024-01-16 LTS';
private const JAVA_PARTIAL_VERSION = '21.0.2_13';
private const JAVA_URL_PATH_NAME = '21.0.2+13';
public const PDFTK_VERSION = '3.3.3';
/**
* When update, verify the hash of all architectures
*/
public const CFSSL_VERSION = '1.6.4';
/** @var ICache */
private $cache;
/** @var OutputInterface */
private $output;
/** @var string */
private $resource = '';
protected IAppData $appData;
private array $availableResources = [
'java',
'jsignpdf',
'pdftk',
'cfssl'
];
private string $architecture;
private bool $willUseLocalCert = false;
public function __construct(
ICacheFactory $cacheFactory,
private IClientService $clientService,
private CertificateEngineHandler $certificateEngineHandler,
private IConfig $config,
private IAppConfig $appConfig,
private IRootFolder $rootFolder,
private LoggerInterface $logger,
private SignSetupService $signSetupService,
protected IAppDataFactory $appDataFactory,
) {
$this->cache = $cacheFactory->createDistributed('libresign-setup');
$this->appData = $appDataFactory->get('libresign');
$this->setArchitecture(php_uname('m'));
}
public function setOutput(OutputInterface $output): void {
$this->output = $output;
}
public function setArchitecture(string $architecture): void {
$this->architecture = $architecture;
}
private function getFolder(string $path = '', ?ISimpleFolder $folder = null, $needToBeEmpty = false): ISimpleFolder {
if (!$folder) {
$folder = $this->appData->newFolder('');
if (!$path) {
$path = $this->architecture;
} else {
$path = $this->architecture . '/' . $path;
}
$path = explode('/', $path);
foreach ($path as $snippet) {
$folder = $this->getFolder($snippet, $folder, $needToBeEmpty);
}
return $folder;
}
try {
$folder = $folder->getFolder($path, $folder);
if ($needToBeEmpty && $path !== $this->architecture) {
$folder->delete();
$path = '';
throw new \Exception('Need to be empty');
}
} catch (\Throwable $th) {
try {
$folder = $folder->newFolder($path);
} catch (NotPermittedException $e) {
$user = posix_getpwuid(posix_getuid());
throw new LibresignException(
$e->getMessage() . '. ' .
'Permission problems. ' .
'Maybe this could fix: chown -R ' . $user['name'] . ' ' . $this->getDataDir()
);
}
}
return $folder;
}
private function getEmptyFolder(string $path): ISimpleFolder {
return $this->getFolder($path, null, true);
}
/**
* @todo check a best solution to don't use reflection
*/
private function getInternalPathOfFolder(ISimpleFolder $node): string {
$reflection = new \ReflectionClass($node);
$reflectionProperty = $reflection->getProperty('folder');
$reflectionProperty->setAccessible(true);
$folder = $reflectionProperty->getValue($node);
$path = $folder->getInternalPath();
return $path;
}
/**
* @todo check a best solution to don't use reflection
*/
private function getInternalPathOfFile(ISimpleFile $node): string {
$reflection = new \ReflectionClass($node);
if ($reflection->hasProperty('parentFolder')) {
$reflectionProperty = $reflection->getProperty('parentFolder');
$reflectionProperty->setAccessible(true);
$folder = $reflectionProperty->getValue($node);
$path = $folder->getInternalPath() . '/' . $node->getName();
} elseif ($reflection->hasProperty('file')) {
$reflectionProperty = $reflection->getProperty('file');
$reflectionProperty->setAccessible(true);
$file = $reflectionProperty->getValue($node);
$path = $file->getPath();
}
return $path;
}
private function getDataDir(): string {
$dataDir = $this->config->getSystemValue('datadirectory', \OC::$SERVERROOT . '/data/');
return $dataDir;
}
private function getFullPath(): string {
$folder = $this->getFolder();
return $this->getDataDir() . '/' . $this->getInternalPathOfFolder($folder);
}
private function runAsync(): void {
$resource = $this->resource;
$process = new Process([OC::$SERVERROOT . '/occ', 'libresign:install', '--' . $resource]);
$process->setOptions(['create_new_console' => true]);
$process->setTimeout(null);
$process->start();
$data['pid'] = $process->getPid();
if ($data['pid']) {
$this->setCache($resource, $data);
} else {
$this->logger->error('Error to get PID of background install proccess. Command: ' . OC::$SERVERROOT . '/occ libresign:install --' . $resource);
}
}
private function progressToDatabase(int $downloadSize, int $downloaded): void {
$data = $this->getProressData();
$data['download_size'] = $downloadSize;
$data['downloaded'] = $downloaded;
$this->setCache($this->resource, $data);
}
public function getProressData(): array {
$data = $this->getCache($this->resource) ?? [];
return $data;
}
private function removeDownloadProgress(): void {
$this->removeCache($this->resource);
}
/**
* @param string $key
* @param mixed $value
*/
private function setCache(string $key, $value): void {
if ($this->cache instanceof NullCache) {
$appFolder = $this->getFolder();
try {
$file = $appFolder->getFile('setup-cache.json');
} catch (\Throwable $th) {
$file = $appFolder->newFile('setup-cache.json', '[]');
}
$json = $file->getContent() ? json_decode($file->getContent(), true) : [];
$json[$key] = $value;
$file->putContent(json_encode($json));
return;
}
$this->cache->set(Application::APP_ID . '-asyncDownloadProgress-' . $key, $value);
}
/**
* @return mixed
*/
private function getCache(string $key) {
if ($this->cache instanceof NullCache) {
$appFolder = $this->getFolder();
try {
$file = $appFolder->getFile('setup-cache.json');
$json = $file->getContent() ? json_decode($file->getContent(), true) : [];
return $json[$key] ?? null;
} catch (NotFoundException $th) {
} catch (\Throwable $th) {
$this->logger->error('Unexpected error when get setup-cache.json file', [
'app' => Application::APP_ID,
'exception' => $th,
]);
}
return;
}
return $this->cache->get(Application::APP_ID . '-asyncDownloadProgress-' . $key);
}
private function removeCache(string $key): void {
if ($this->cache instanceof NullCache) {
$appFolder = $this->getFolder();
try {
$file = $appFolder->getFile('setup-cache.json');
$json = $file->getContent() ? json_decode($file->getContent(), true) : [];
if (isset($json[$key])) {
unset($json[$key]);
}
if (!$json) {
$file->delete();
} else {
$file->putContent(json_encode($json));
}
} catch (\Throwable $th) {
}
return;
}
$this->cache->remove(Application::APP_ID . '-asyncDownloadProgress-' . $key);
}
public function getAvailableResources(): array {
return $this->availableResources;
}
public function getTotalSize(): array {
$return = [];
foreach ($this->availableResources as $resource) {
$this->setResource($resource);
$progressData = $this->getProressData();
if (array_key_exists('download_size', $progressData)) {
if ($progressData['download_size']) {
$return[$resource] = $progressData['downloaded'] * 100 / $progressData['download_size'];
} else {
$return[$resource] = 0;
}
}
}
return $return;
}
public function saveErrorMessage(string $message): void {
$data = $this->getProressData();
$data['error'] = $message;
$this->setCache($this->resource, $data);
}
public function getErrorMessages(): array {
$return = [];
foreach ($this->availableResources as $resource) {
$this->setResource($resource);
$progressData = $this->getProressData();
if (array_key_exists('error', $progressData)) {
$return[] = $progressData['error'];
$this->removeDownloadProgress();
}
}
return $return;
}
public function isDownloadWip(): bool {
foreach ($this->availableResources as $resource) {
$this->setResource($resource);
$progressData = $this->getProressData();
if (empty($progressData)) {
return false;
}
$pid = isset($progressData['pid']) ? $progressData['pid'] : 0;
if ($this->getInstallPid($pid) === 0) {
if (!array_key_exists('error', $progressData)) {
$this->removeDownloadProgress();
}
continue;
}
return true;
}
return false;
}
private function getInstallPid(int $pid = 0): int {
if ($pid > 0) {
$cmd = 'ps -p ' . $pid . ' -o pid,command|';
} else {
$cmd = 'ps -eo pid,command|';
}
$cmd .= 'grep "libresign:install --' . $this->resource . '"|' .
'grep -v grep|' .
'grep -v defunct|' .
'sed -e "s/^[[:space:]]*//"|cut -d" " -f1';
$output = shell_exec($cmd);
if (!is_string($output)) {
return 0;
}
$pid = trim($output);
return (int) $pid;
}
public function setResource(string $resource): self {
$this->resource = $resource;
return $this;
}
public function isDownloadedFilesOk(): bool {
$this->signSetupService->willUseLocalCert($this->willUseLocalCert);
return count($this->signSetupService->verify($this->architecture, $this->resource)) === 0;
}
public function willUseLocalCert(): void {
$this->willUseLocalCert = true;
}
private function writeAppSignature(): void {
if (!$this->willUseLocalCert) {
return;
}
$this->signSetupService->writeAppSignature($this->architecture, $this->resource);
}
public function installJava(?bool $async = false): void {
$this->setResource('java');
if ($async) {
$this->runAsync();
return;
}
$extractDir = $this->getFullPath() . '/' . $this->resource;
if ($this->isDownloadedFilesOk()) {
$folder = $this->getFolder($this->resource);
} else {
$folder = $this->getEmptyFolder($this->resource);
/**
* Steps to update:
* Check the compatible version of Java to use JSignPdf
* Update all the follow data
* Update the constants with java version
* URL used to get the MD5 and URL to download:
* https://jdk.java.net/java-se-ri/8-MR3
*/
if (PHP_OS_FAMILY === 'Linux') {
$linuxDistribution = $this->getLinuxDistributionToDownloadJava();
if ($this->architecture === 'x86_64') {
$compressedFileName = 'OpenJDK21U-jre_x64_' . $linuxDistribution . '_hotspot_' . self::JAVA_PARTIAL_VERSION . '.tar.gz';
$url = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-' . self::JAVA_URL_PATH_NAME . '/' . $compressedFileName;
} elseif ($this->architecture === 'aarch64') {
$compressedFileName = 'OpenJDK21U-jre_aarch64_' . $linuxDistribution . '_hotspot_' . self::JAVA_PARTIAL_VERSION . '.tar.gz';
$url = 'https://github.com/adoptium/temurin21-binaries/releases/download/jdk-' . self::JAVA_URL_PATH_NAME . '/' . $compressedFileName;
}
$class = TAR::class;
} else {
throw new RuntimeException(sprintf('OS_FAMILY %s is incompatible with LibreSign.', PHP_OS_FAMILY));
}
$checksumUrl = $url . '.sha256.txt';
$hash = $this->getHash($compressedFileName, $checksumUrl);
try {
$compressedFile = $folder->getFile($compressedFileName);
} catch (NotFoundException $th) {
$compressedFile = $folder->newFile($compressedFileName);
}
$comporessedInternalFileName = $this->getDataDir() . '/' . $this->getInternalPathOfFile($compressedFile);
$this->download($url, 'java', $comporessedInternalFileName, $hash, 'sha256');
$extractor = new $class($comporessedInternalFileName);
$extractor->extract($extractDir);
unlink($comporessedInternalFileName);
$this->writeAppSignature();
}
$this->appConfig->setAppValue('java_path', $extractDir . '/jdk-' . self::JAVA_URL_PATH_NAME . '-jre/bin/java');
$this->removeDownloadProgress();
}
/**
* Return linux or alpine-linux
*/
private function getLinuxDistributionToDownloadJava(): string {
$distribution = shell_exec('cat /etc/*-release');
preg_match('/^ID=(?<version>.*)$/m', $distribution, $matches);
if (isset($matches['version']) && strtolower($matches['version']) === 'alpine') {
return 'alpine-linux';
}
return 'linux';
}
public function uninstallJava(): void {
$javaPath = $this->appConfig->getAppValue('java_path');
if (!$javaPath) {
return;
}
$this->setResource('java');
$folder = $this->getFolder($this->resource);
try {
$folder->delete();
} catch (NotFoundException $th) {
}
$this->appConfig->deleteAppValue('java_path');
}
public function installJSignPdf(?bool $async = false): void {
if (!extension_loaded('zip')) {
throw new RuntimeException('Zip extension is not available');
}
$this->setResource('jsignpdf');
if ($async) {
$this->runAsync();
return;
}
$extractDir = $this->getFullPath() . '/' . $this->resource;
if ($this->isDownloadedFilesOk()) {
$folder = $this->getFolder($this->resource);
} else {
$folder = $this->getEmptyFolder($this->resource);
$compressedFileName = 'jsignpdf-' . JSignPdfHandler::VERSION . '.zip';
try {
$compressedFile = $folder->getFile($compressedFileName);
} catch (\Throwable $th) {
$compressedFile = $folder->newFile($compressedFileName);
}
$comporessedInternalFileName = $this->getDataDir() . '/' . $this->getInternalPathOfFile($compressedFile);
$url = 'https://github.com/intoolswetrust/jsignpdf/releases/download/JSignPdf_' . str_replace('.', '_', JSignPdfHandler::VERSION) . '/jsignpdf-' . JSignPdfHandler::VERSION . '.zip';
/** WHEN UPDATE version: generate this hash handmade and update here */
$hash = '7c66f5a9f5e7e35b601725414491a867';
$this->download($url, 'JSignPdf', $comporessedInternalFileName, $hash);
$zip = new ZIP($extractDir . '/' . $compressedFileName);
$zip->extract($extractDir);
unlink($extractDir . '/' . $compressedFileName);
$this->writeAppSignature();
}
$fullPath = $extractDir . '/jsignpdf-' . JSignPdfHandler::VERSION . '/JSignPdf.jar';
$this->appConfig->setAppValue('jsignpdf_jar_path', $fullPath);
$this->removeDownloadProgress();
}
public function uninstallJSignPdf(): void {
$jsignpdJarPath = $this->appConfig->getAppValue('jsignpdf_jar_path');
if (!$jsignpdJarPath) {
return;
}
$this->setResource('jsignpdf');
$folder = $this->getFolder($this->resource);
try {
$folder->delete();
} catch (NotFoundException $e) {
}
$this->appConfig->deleteAppValue('jsignpdf_jar_path');
}
public function installPdftk(?bool $async = false): void {
$this->setResource('pdftk');
if ($async) {
$this->runAsync();
return;
}
if ($this->isDownloadedFilesOk()) {
$folder = $this->getFolder($this->resource);
$file = $folder->getFile('pdftk.jar');
$fullPath = $this->getDataDir() . '/' . $this->getInternalPathOfFile($file);
} else {
$folder = $this->getEmptyFolder($this->resource);
try {
$file = $folder->getFile('pdftk.jar');
} catch (\Throwable $th) {
$file = $folder->newFile('pdftk.jar');
}
$fullPath = $this->getDataDir() . '/' . $this->getInternalPathOfFile($file);
$url = 'https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/v' . self::PDFTK_VERSION . '/pdftk-all.jar';
/** WHEN UPDATE version: generate this hash handmade and update here */
$hash = '59a28bed53b428595d165d52988bf4cf';
$this->download($url, 'pdftk', $fullPath, $hash);
$this->writeAppSignature();
}
$this->appConfig->setAppValue('pdftk_path', $fullPath);
$this->removeDownloadProgress();
}
public function uninstallPdftk(): void {
$jsignpdJarPath = $this->appConfig->getAppValue('pdftk_path');
if (!$jsignpdJarPath) {
return;
}
$this->setResource('pdftk');
$folder = $this->getFolder($this->resource);
try {
$folder->delete();
} catch (NotFoundException $e) {
}
$this->appConfig->deleteAppValue('pdftk_path');
}
public function installCfssl(?bool $async = false): void {
$this->setResource('cfssl');
if ($async) {
$this->runAsync();
return;
}
if (PHP_OS_FAMILY !== 'Linux') {
throw new RuntimeException(sprintf('OS_FAMILY %s is incompatible with LibreSign.', PHP_OS_FAMILY));
}
if ($this->architecture === 'x86_64') {
$this->installCfsslByArchitecture('amd64');
} elseif ($this->architecture === 'aarch64') {
$this->installCfsslByArchitecture('arm64');
} else {
throw new InvalidArgumentException('Invalid architecture to download cfssl');
}
$this->removeDownloadProgress();
}
private function installCfsslByArchitecture(string $arcitecture): void {
if ($this->isDownloadedFilesOk()) {
$folder = $this->getFolder($this->resource);
} else {
$folder = $this->getEmptyFolder($this->resource);
$downloads = [
[
'file' => 'cfssl_' . self::CFSSL_VERSION . '_linux_' . $arcitecture,
'destination' => 'cfssl',
],
[
'file' => 'cfssljson_' . self::CFSSL_VERSION . '_linux_' . $arcitecture,
'destination' => 'cfssljson',
],
];
$baseUrl = 'https://github.com/cloudflare/cfssl/releases/download/v' . self::CFSSL_VERSION . '/';
$checksumUrl = 'https://github.com/cloudflare/cfssl/releases/download/v' . self::CFSSL_VERSION . '/cfssl_' . self::CFSSL_VERSION . '_checksums.txt';
foreach ($downloads as $download) {
$hash = $this->getHash($download['file'], $checksumUrl);
$file = $folder->newFile($download['destination']);
$fullPath = $this->getDataDir() . '/' . $this->getInternalPathOfFile($file);
$this->download($baseUrl . $download['file'], $download['destination'], $fullPath, $hash, 'sha256');
chmod($fullPath, 0700);
}
$this->writeAppSignature();
}
$cfsslBinPath = $this->getDataDir() . '/' .
$this->getInternalPathOfFolder($folder) . '/cfssl';
$this->appConfig->setAppValue('cfssl_bin', $cfsslBinPath);
}
public function uninstallCfssl(): void {
$cfsslPath = $this->appConfig->getAppValue('cfssl_bin');
if (!$cfsslPath) {
return;
}
$this->setResource('cfssl');
$folder = $this->getFolder($this->resource);
try {
$folder->delete();
} catch (NotFoundException $e) {
}
$this->appConfig->deleteAppValue('cfssl_bin');
}
public function isCfsslBinInstalled(): bool {
if ($this->appConfig->getAppValue('cfssl_bin')) {
return true;
}
return false;
}
protected function download(string $url, string $filename, string $path, ?string $hash = '', ?string $hash_algo = 'md5'): void {
if (file_exists($path)) {
$this->progressToDatabase((int) filesize($path), 0);
if (hash_file($hash_algo, $path) === $hash) {
return;
}
}
if (php_sapi_name() === 'cli' && $this->output instanceof OutputInterface) {
$this->downloadCli($url, $filename, $path, $hash, $hash_algo);
return;
}
$client = $this->clientService->newClient();
try {
$client->get($url, [
'sink' => $path,
'timeout' => 0,
'progress' => function ($downloadSize, $downloaded) {
$this->progressToDatabase($downloadSize, $downloaded);
},
]);
} catch (\Exception $e) {
throw new LibresignException('Failure on download ' . $filename . " try again.\n" . $e->getMessage());
}
if ($hash && file_exists($path) && hash_file($hash_algo, $path) !== $hash) {
throw new LibresignException('Failure on download ' . $filename . ' try again. Invalid ' . $hash_algo . '.');
}
}
protected function downloadCli(string $url, string $filename, string $path, ?string $hash = '', ?string $hash_algo = 'md5'): void {
$client = $this->clientService->newClient();
$progressBar = new ProgressBar($this->output);
$this->output->writeln('Downloading ' . $filename . '...');
$progressBar->start();
try {
$client->get($url, [
'sink' => $path,
'timeout' => 0,
'progress' => function ($downloadSize, $downloaded) use ($progressBar) {
$progressBar->setMaxSteps($downloadSize);
$progressBar->setProgress($downloaded);
$this->progressToDatabase($downloadSize, $downloaded);
},
]);
} catch (\Exception $e) {
$progressBar->finish();
$this->output->writeln('');
$this->output->writeln('<error>Failure on download ' . $filename . ' try again.</error>');
$this->output->writeln('<error>' . $e->getMessage() . '</error>');
$this->logger->error('Failure on download ' . $filename . '. ' . $e->getMessage());
} finally {
$progressBar->finish();
$this->output->writeln('');
}
if ($hash && file_exists($path) && hash_file($hash_algo, $path) !== $hash) {
$this->output->writeln('<error>Failure on download ' . $filename . ' try again</error>');
$this->output->writeln('<error>Invalid ' . $hash_algo . '</error>');
$this->logger->error('Failure on download ' . $filename . '. Invalid ' . $hash_algo . '.');
}
if (!file_exists($path)) {
$this->output->writeln('<error>Failure on download ' . $filename . ', empty file, try again</error>');
$this->logger->error('Failure on download ' . $filename . ', empty file.');
}
}
private function getHash(string $file, string $checksumUrl): string {
$hashes = file_get_contents($checksumUrl);
if (!$hashes) {
throw new LibresignException('Failute to download hash file. URL: ' . $checksumUrl);
}
preg_match('/(?<hash>\w*) +' . $file . '/', $hashes, $matches);
return $matches['hash'];
}
/**
* @todo Use an custom array for engine options
*/
public function generate(
string $commonName,
array $names = [],
array $properties = [],
): void {
$rootCert = [
'commonName' => $commonName,
'names' => $names
];
$engine = $this->certificateEngineHandler->getEngine($properties['engine'] ?? '', $rootCert);
if ($engine->getEngine() === 'cfssl') {
/** @var CfsslHandler $engine */
$engine->setCfsslUri($properties['cfsslUri']);
}
$engine->setConfigPath($properties['configPath'] ?? '');
/** @var IEngineHandler $engine */
$privateKey = $engine->generateRootCert(
$commonName,
$names
);
$this->appConfig->setAppValue('root_cert', json_encode($rootCert));
$this->appConfig->setAppValue('authkey', $privateKey);
/** @var AEngineHandler $engine */
if ($engine->getEngine() === 'cfssl') {
$this->appConfig->setAppValue('config_path', $engine->getConfigPath());
}
$this->appConfig->setAppValue('notify_unsigned_user', '1');
}
}