circles/lib/Command/CirclesRemote.php
John Molakvoæ (skjnldsv) adca9135ad
Cs fix
Signed-off-by: John Molakvoæ (skjnldsv) <skjnldsv@protonmail.com>
2021-07-07 09:51:03 +02:00

530 lines
16 KiB
PHP

<?php
declare(strict_types=1);
/**
* Circles - Bring cloud-users closer together.
*
* This file is licensed under the Affero General Public License version 3 or
* later. See the COPYING file.
*
* @author Maxence Lange <maxence@artificial-owl.com>
* @copyright 2021
* @license GNU AGPL version 3 or any later version
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
*/
namespace OCA\Circles\Command;
use ArtificialOwl\MySmallPhpTools\Exceptions\RequestNetworkException;
use ArtificialOwl\MySmallPhpTools\Exceptions\SignatoryException;
use ArtificialOwl\MySmallPhpTools\Exceptions\SignatureException;
use ArtificialOwl\MySmallPhpTools\Model\Nextcloud\nc22\NC22Request;
use ArtificialOwl\MySmallPhpTools\Model\Nextcloud\nc22\NC22SignedRequest;
use ArtificialOwl\MySmallPhpTools\Traits\Nextcloud\nc22\TNC22WellKnown;
use ArtificialOwl\MySmallPhpTools\Traits\TStringTools;
use Exception;
use OC\Core\Command\Base;
use OCA\Circles\AppInfo\Application;
use OCA\Circles\Db\RemoteRequest;
use OCA\Circles\Exceptions\RemoteNotFoundException;
use OCA\Circles\Exceptions\RemoteUidException;
use OCA\Circles\Model\Federated\RemoteInstance;
use OCA\Circles\Service\ConfigService;
use OCA\Circles\Service\GlobalScaleService;
use OCA\Circles\Service\InterfaceService;
use OCA\Circles\Service\RemoteStreamService;
use Symfony\Component\Console\Helper\Table;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\ConsoleOutput;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Console\Question\ConfirmationQuestion;
/**
* Class CirclesRemote
*
* @package OCA\Circles\Command
*/
class CirclesRemote extends Base {
use TNC22WellKnown;
use TStringTools;
/** @var RemoteRequest */
private $remoteRequest;
/** @var GlobalScaleService */
private $globalScaleService;
/** @var RemoteStreamService */
private $remoteStreamService;
/** @var InterfaceService */
private $interfaceService;
/** @var ConfigService */
private $configService;
/** @var InputInterface */
private $input;
/** @var OutputInterface */
private $output;
/**
* CirclesRemote constructor.
*
* @param RemoteRequest $remoteRequest
* @param GlobalScaleService $globalScaleService
* @param RemoteStreamService $remoteStreamService
* @param InterfaceService $interfaceService
* @param ConfigService $configService
*/
public function __construct(
RemoteRequest $remoteRequest,
GlobalScaleService $globalScaleService,
RemoteStreamService $remoteStreamService,
InterfaceService $interfaceService,
ConfigService $configService
) {
parent::__construct();
$this->remoteRequest = $remoteRequest;
$this->globalScaleService = $globalScaleService;
$this->remoteStreamService = $remoteStreamService;
$this->interfaceService = $interfaceService;
$this->configService = $configService;
$this->setup('app', 'circles');
}
/**
*
*/
protected function configure() {
parent::configure();
$this->setName('circles:remote')
->setDescription('remote features')
->addArgument('host', InputArgument::OPTIONAL, 'host of the remote instance of Nextcloud')
->addOption(
'type', '', InputOption::VALUE_REQUIRED, 'set type of remote', RemoteInstance::TYPE_UNKNOWN
)
->addOption(
'iface', '', InputOption::VALUE_REQUIRED, 'set interface to use to contact remote',
InterfaceService::$LIST_IFACE[InterfaceService::IFACE_FRONTAL]
)
->addOption('yes', '', InputOption::VALUE_NONE, 'silently add the remote instance')
->addOption('all', '', InputOption::VALUE_NONE, 'display all information');
}
/**
* @param InputInterface $input
* @param OutputInterface $output
*
* @return int
* @throws Exception
*/
protected function execute(InputInterface $input, OutputInterface $output): int {
$host = $input->getArgument('host');
$this->input = $input;
$this->output = $output;
if ($host) {
$this->requestInstance($host);
} else {
$this->checkKnownInstance();
}
return 0;
}
/**
* @param string $host
*
* @throws Exception
*/
private function requestInstance(string $host): void {
$remoteType = $this->getRemoteType();
$remoteIface = $this->getRemoteInterface();
$this->interfaceService->setCurrentInterface($remoteIface);
$webfinger = $this->getWebfinger($host, Application::APP_SUBJECT);
if ($this->input->getOption('all')) {
$this->output->writeln('- Webfinger on <info>' . $host . '</info>');
$this->output->writeln(json_encode($webfinger, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->output->writeln('');
}
if ($this->input->getOption('all')) {
$circleLink = $this->extractLink(Application::APP_REL, $webfinger);
$this->output->writeln('- Information about Circles app on <info>' . $host . '</info>');
$this->output->writeln(json_encode($circleLink, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->output->writeln('');
}
$this->output->writeln('- Available services on <info>' . $host . '</info>');
foreach ($webfinger->getLinks() as $link) {
$app = $link->getProperty('name');
$ver = $link->getProperty('version');
if ($app !== '') {
$app .= ' ';
}
if ($ver !== '') {
$ver = 'v' . $ver;
}
$this->output->writeln(' * ' . $link->getRel() . ' ' . $app . $ver);
}
$this->output->writeln('');
$this->output->writeln('- Resources related to Circles on <info>' . $host . '</info>');
$resource = $this->getResourceData($host, Application::APP_SUBJECT, Application::APP_REL);
$this->output->writeln(json_encode($resource, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->output->writeln('');
$tempUid = $resource->g('uid');
$this->output->writeln(
'- Confirming UID=' . $tempUid . ' from parsed Signatory at <info>' . $host . '</info>'
);
try {
/** @var RemoteInstance $remoteSignatory */
$remoteSignatory = $this->remoteStreamService->retrieveSignatory($resource->g('id'), true);
$this->output->writeln(' * No SignatureException: <info>Identity authed</info>');
} catch (SignatureException $e) {
$this->output->writeln(
'<error>' . $host . ' cannot auth its identity: ' . $e->getMessage() . '</error>'
);
return;
}
$this->output->writeln(' * Found <info>' . $remoteSignatory->getUid() . '</info>');
if ($remoteSignatory->getUid(true) !== $tempUid) {
$this->output->writeln('<error>looks like ' . $host . ' is faking its identity');
return;
}
$this->output->writeln('');
$testUrl = $resource->g('test');
$this->output->writeln('- Testing signed payload on <info>' . $testUrl . '</info>');
try {
$localSignatory = $this->remoteStreamService->getAppSignatory();
} catch (SignatoryException $e) {
$this->output->writeln(
'<error>Federated Circles not enabled locally. Please run ./occ circles:remote:init</error>'
);
return;
}
$payload = [
'test' => 42,
'token' => $this->uuid()
];
$signedRequest = $this->outgoingTest($testUrl, $payload);
$this->output->writeln(' * Payload: ');
$this->output->writeln(json_encode($payload, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->output->writeln('');
$this->output->writeln(' * Clear Signature: ');
$this->output->writeln('<comment>' . $signedRequest->getClearSignature() . '</comment>');
$this->output->writeln('');
$this->output->writeln(' * Signed Signature (base64 encoded): ');
$this->output->writeln(
'<comment>' . base64_encode($signedRequest->getSignedSignature()) . '</comment>'
);
$this->output->writeln('');
$result = $signedRequest->getOutgoingRequest()->getResult();
$code = $result->getStatusCode();
$this->output->writeln(' * Result: ' . (($code === 200) ? '<info>' . $code . '</info>' : $code));
$this->output->writeln(
json_encode(json_decode($result->getContent(), true), JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES)
);
$this->output->writeln('');
if ($this->input->getOption('all')) {
$this->output->writeln('');
$this->output->writeln('<info>### Complete report ###</info>');
$this->output->writeln(json_encode($signedRequest, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES));
$this->output->writeln('');
}
if ($remoteSignatory->getUid() !== $localSignatory->getUid()) {
$remoteSignatory->setInstance($host)
->setType($remoteType)
->setInterface($remoteIface);
try {
$stored = new RemoteInstance();
$this->remoteStreamService->confirmValidRemote($remoteSignatory, $stored);
$this->output->writeln(
'<info>The remote instance ' . $host
. ' is already known with this current identity</info>'
);
$this->output->writeln('- updating item');
$this->remoteStreamService->update($remoteSignatory, RemoteStreamService::UPDATE_ITEM);
if ($remoteSignatory->getType() !== $stored->getType()) {
$this->output->writeln(
'- updating type from ' . $stored->getType() . ' to '
. $remoteSignatory->getType()
);
$this->remoteStreamService->update(
$remoteSignatory, RemoteStreamService::UPDATE_TYPE
);
}
if ($remoteSignatory->getInstance() !== $stored->getInstance()) {
$this->output->writeln(
'- updating host from ' . $stored->getInstance() . ' to '
. $remoteSignatory->getInstance()
);
$this->remoteStreamService->update(
$remoteSignatory, RemoteStreamService::UPDATE_INSTANCE
);
}
if ($remoteSignatory->getId() !== $stored->getId()) {
$this->output->writeln(
'- updating href/Id from ' . $stored->getId() . ' to '
. $remoteSignatory->getId()
);
$this->remoteStreamService->update($remoteSignatory, RemoteStreamService::UPDATE_HREF);
}
} catch (RemoteUidException $e) {
$this->updateRemote($remoteSignatory);
} catch (RemoteNotFoundException $e) {
$this->saveRemote($remoteSignatory);
}
}
}
/**
* @param RemoteInstance $remoteSignatory
*
* @throws RemoteUidException
*/
private function saveRemote(RemoteInstance $remoteSignatory) {
$this->output->writeln('');
$helper = $this->getHelper('question');
$this->output->writeln(
'The remote instance <info>' . $remoteSignatory->getInstance() . '</info> looks good.'
);
$question = new ConfirmationQuestion(
'Would you like to identify this remote instance as \'<comment>' . $remoteSignatory->getType()
. '</comment>\' using interface \'<comment>'
. InterfaceService::$LIST_IFACE[$remoteSignatory->getInterface()]
. '</comment>\' ? (y/N) ',
false,
'/^(y|Y)/i'
);
if ($this->input->getOption('yes') || $helper->ask($this->input, $this->output, $question)) {
if (!$this->interfaceService->isInterfaceInternal($remoteSignatory->getInterface())) {
$remoteSignatory->setAliases([]);
}
$this->remoteRequest->save($remoteSignatory);
$this->output->writeln('<info>remote instance saved</info>');
}
}
/**
* @param RemoteInstance $remoteSignatory
*
* @throws RemoteUidException
*/
private function updateRemote(RemoteInstance $remoteSignatory): void {
$this->output->writeln('');
$helper = $this->getHelper('question');
$this->output->writeln(
'The remote instance <info>' . $remoteSignatory->getInstance()
. '</info> is known but <error>its identity has changed.</error>'
);
$this->output->writeln(
'<comment>If you are not sure on why identity changed, please say No to the next question and contact the admin of the remote instance</comment>'
);
$question = new ConfirmationQuestion(
'Do you consider this new identity as valid and update the entry in the database? (y/N) ',
false,
'/^(y|Y)/i'
);
if ($helper->ask($this->input, $this->output, $question)) {
$this->remoteStreamService->update($remoteSignatory);
$this->output->writeln('remote instance updated');
}
}
/**
* @param string $remote
* @param array $payload
*
* @return NC22SignedRequest
* @throws RequestNetworkException
* @throws SignatoryException
*/
private function outgoingTest(string $remote, array $payload): NC22SignedRequest {
$request = new NC22Request();
$request->basedOnUrl($remote);
$request->setFollowLocation(true);
$request->setLocalAddressAllowed(true);
$request->setTimeout(5);
$request->setData($payload);
$app = $this->remoteStreamService->getAppSignatory();
$signedRequest = $this->remoteStreamService->signOutgoingRequest($request, $app);
$this->doRequest($signedRequest->getOutgoingRequest());
return $signedRequest;
}
/**
*
*/
private function checkKnownInstance(): void {
$this->verifyGSInstances();
$this->checkRemoteInstances();
}
/**
*
*/
private function verifyGSInstances(): void {
$instances = $this->globalScaleService->getGlobalScaleInstances();
$known = array_map(
function (RemoteInstance $instance): string {
return $instance->getInstance();
}, $this->remoteRequest->getFromType(RemoteInstance::TYPE_GLOBALSCALE)
);
$missing = array_diff($instances, $known);
foreach ($missing as $instance) {
$this->syncGSInstance($instance);
}
}
/**
* @param string $instance
*/
private function syncGSInstance(string $instance): void {
if ($this->configService->isLocalInstance($instance)) {
return;
}
$this->output->write('Adding <comment>' . $instance . '</comment>: ');
try {
$this->remoteStreamService->addRemoteInstance(
$instance,
RemoteInstance::TYPE_GLOBALSCALE,
InterfaceService::IFACE_INTERNAL,
true
);
$this->output->writeln('<info>ok</info>');
} catch (Exception $e) {
$msg = ($e->getMessage() === '') ? '' : ' (' . $e->getMessage() . ')';
$this->output->writeln('<error>' . get_class($e) . $msg . '</error>');
}
}
private function checkRemoteInstances(): void {
$instances = $this->remoteRequest->getAllInstances();
$output = new ConsoleOutput();
$output = $output->section();
$table = new Table($output);
$table->setHeaders(['Instance', 'Type', 'iface', 'UID', 'Authed', 'Aliases']);
$table->render();
foreach ($instances as $instance) {
try {
$current = $this->remoteStreamService->retrieveRemoteInstance($instance->getInstance());
if ($current->getUid(true) === $instance->getUid(true)) {
$currentUid = '<info>' . $current->getUid(true) . '</info>';
} else {
$currentUid = '<error>' . $current->getUid(true) . '</error>';
}
} catch (Exception $e) {
$currentUid = '<error>' . $e->getMessage() . '</error>';
}
$table->appendRow(
[
$instance->getInstance(),
$instance->getType(),
InterfaceService::$LIST_IFACE[$instance->getInterface()],
$instance->getUid(),
$currentUid,
json_encode($instance->getAliases())
]
);
}
}
/**
* @throws Exception
*/
private function getRemoteType(): string {
foreach (RemoteInstance::$LIST_TYPE as $type) {
if (strtolower($this->input->getOption('type')) === strtolower($type)) {
return $type;
}
}
throw new Exception('Unknown type: ' . implode(', ', RemoteInstance::$LIST_TYPE));
}
/**
* @throws Exception
*/
private function getRemoteInterface(): int {
foreach (InterfaceService::$LIST_IFACE as $iface => $def) {
if (strtolower($this->input->getOption('iface')) === strtolower($def)) {
return $iface;
}
}
throw new Exception('Unknown interface: ' . implode(', ', InterfaceService::$LIST_IFACE));
}
}