Skip to content
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

enh: add exapps info endpoint for HaRP #505

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
4 changes: 4 additions & 0 deletions appinfo/routes.php
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,10 @@
['name' => 'DaemonConfig#startTestDeploy', 'url' => '/daemons/{name}/test_deploy', 'verb' => 'POST'],
['name' => 'DaemonConfig#stopTestDeploy', 'url' => '/daemons/{name}/test_deploy', 'verb' => 'DELETE'],
['name' => 'DaemonConfig#getTestDeployStatus', 'url' => '/daemons/{name}/test_deploy/status', 'verb' => 'GET'],

// HaRP actions
['name' => 'Harp#getExAppMetadata', 'url' => '/harp/exapp-meta', 'verb' => 'GET'],
['name' => 'Harp#getUserInfo', 'url' => '/harp/user-info', 'verb' => 'GET'],
],
'ocs' => [
// Logging
Expand Down
7 changes: 5 additions & 2 deletions lib/Command/Daemon/ListDaemons.php
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int

$output->writeln('Registered ExApp daemon configs:');
$table = new Table($output);
$table->setHeaders(['Def', 'Name', 'Display name', 'Deploy ID', 'Protocol', 'Host', 'NC Url']);
$table->setHeaders(['Def', 'Name', 'Display name', 'Deploy ID', 'Protocol', 'Host', 'NC Url', 'Is HaRP', 'HaRP FRP Address', 'HaRP Docker Socket Port']);
$rows = [];

foreach ($daemonConfigs as $daemon) {
Expand All @@ -53,7 +53,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$daemon->getAcceptsDeployId(),
$daemon->getProtocol(),
$daemon->getHost(),
$daemon->getDeployConfig()['nextcloud_url']
$daemon->getDeployConfig()['nextcloud_url'],
boolval($daemon->getDeployConfig()['harp'] ?? false) ? 'yes' : 'no',
$daemon->getDeployConfig()['harp_frp_address'] ?? '(none)',
$daemon->getDeployConfig()['harp_docker_socket_port'] ?? '(none)',
];
}

Expand Down
53 changes: 40 additions & 13 deletions lib/Command/Daemon/RegisterDaemon.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ protected function configure(): void {
$this->setName('app_api:daemon:register');
$this->setDescription('Register daemon config for ExApp deployment');

// todo: add docs
$this->addArgument('name', InputArgument::REQUIRED);
$this->addArgument('display-name', InputArgument::REQUIRED);
$this->addArgument('accepts-deploy-id', InputArgument::REQUIRED);
Expand All @@ -42,13 +43,18 @@ protected function configure(): void {
// daemon-config settings
$this->addOption('net', null, InputOption::VALUE_REQUIRED, 'DeployConfig, the name of the docker network to attach App to');
$this->addOption('haproxy_password', null, InputOption::VALUE_REQUIRED, 'AppAPI Docker Socket Proxy password for HAProxy Basic auth');

$this->addOption('compute_device', null, InputOption::VALUE_REQUIRED, 'Compute device for GPU support (cpu|cuda|rocm)');

$this->addOption('set-default', null, InputOption::VALUE_NONE, 'Set DaemonConfig as default');

$this->addUsage('local_docker "Docker local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud');
$this->addUsage('local_docker "Docker local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
$this->addOption('harp', null, InputOption::VALUE_NONE, 'Set daemon to use HaRP for all docker and exapp communication');
$this->addOption('harp_frp_address', null, InputOption::VALUE_REQUIRED, '[host]:[port] of the HaRP FRP server, default host is same as HaRP host and port is 8782');
$this->addOption('harp_shared_key', null, InputOption::VALUE_REQUIRED, 'HaRP shared key for secure communication between HaRP and AppAPI');
$this->addOption('harp_docker_socket_port', null, InputOption::VALUE_REQUIRED, '\'remotePort\' of the FRP client of the remote docker socket proxy. There is one included in the harp container so this can be skipped for default setups.', '24000');

$this->addUsage('manual_install "Manual Install" "manual-install" "http" null "http://nextcloud.local"');
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud');
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
$this->addUsage('docker_install "Docker Socket Proxy" "docker-install" "http" "nextcloud-appapi-dsp:2375" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
$this->addUsage('harp_install "Harp Install" "docker-install" "http" "nextcloud-appapi-harp:8780" "http://nextcloud.local" --harp --harp_frp_address "nextcloud-appapi-harp:8782" --harp_shared_key "some_very_secure_password" --set-default --compute_device=cuda');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
Expand All @@ -58,28 +64,49 @@ protected function execute(InputInterface $input, OutputInterface $output): int
$protocol = $input->getArgument('protocol');
$host = $input->getArgument('host');
$nextcloudUrl = $input->getArgument('nextcloud_url');

$deployConfig = [
'net' => $input->getOption('net') ?? 'host',
'nextcloud_url' => $nextcloudUrl,
'haproxy_password' => $input->getOption('haproxy_password') ?? '',
'computeDevice' => $this->buildComputeDevice($input->getOption('compute_device') ?? 'cpu'),
];
$isHarp = $input->getOption('harp') !== null;

if (($protocol !== 'http') && ($protocol !== 'https')) {
$output->writeln('Value error: The protocol must be `http` or `https`.');
return 1;
}
if (($acceptsDeployId === 'manual-install') && ($protocol !== 'http')) {
if ($acceptsDeployId === 'manual-install' && $protocol !== 'http') {
$output->writeln('Value error: Manual-install daemon supports only `http` protocol.');
return 1;
}
// todo:
if ($acceptsDeployId === 'manual-install' && $isHarp) {
$output->writeln('Value error: Manual-install daemon does not support HaRP.');
return 1;
}
if ($isHarp && !$input->getOption('harp_shared_key')) {
$output->writeln('Value error: HaRP enabled daemon requires `harp_shared_key` option.');
return 1;
}
if ($isHarp && !$input->getOption('harp_frp_address')) {
$output->writeln('Value error: HaRP enabled daemon requires `harp_frp_address` option.');
return 1;
}

if ($this->daemonConfigService->getDaemonConfigByName($name) !== null) {
$output->writeln(sprintf('Skip registration, as daemon config `%s` already registered.', $name));
return 0;
}

$secret = $isHarp
? $input->getOption('harp_shared_key')
: $input->getOption('haproxy_password') ?? '';

$deployConfig = [
'net' => $input->getOption('net') ?? 'host',
'nextcloud_url' => $nextcloudUrl,
'haproxy_password' => $secret,
'computeDevice' => $this->buildComputeDevice($input->getOption('compute_device') ?? 'cpu'),
'harp' => $isHarp,
'harp_frp_address' => $input->getOption('harp_frp_address') ?? '',
'harp_docker_socket_port' => $input->getOption('harp_docker_socket_port'),
];

$daemonConfig = $this->daemonConfigService->registerDaemonConfig([
'name' => $name,
'display_name' => $displayName,
Expand Down
139 changes: 139 additions & 0 deletions lib/Controller/HarpController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
<?php

declare(strict_types=1);

/**
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

namespace OCA\AppAPI\Controller;

use OCA\AppAPI\AppInfo\Application;
use OCA\AppAPI\Service\ExAppService;
use OCA\AppAPI\Service\HarpService;
use OCP\AppFramework\Controller;
use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
use OCP\AppFramework\Http\Attribute\PublicPage;
use OCP\AppFramework\Http\DataResponse;
use OCP\IAppConfig;
use OCP\IGroupManager;
use OCP\IRequest;
use OCP\IUserManager;
use OCP\Security\Bruteforce\IThrottler;
use Psr\Log\LoggerInterface;

class HarpController extends Controller {
protected $request;

public function __construct(
IRequest $request,
private readonly IAppConfig $appConfig,
private readonly ExAppService $exAppService,
private readonly LoggerInterface $logger,
private readonly IThrottler $throttler,
private readonly IUserManager $userManager,
private readonly IGroupManager $groupManager,
private readonly ?string $userId,
) {
parent::__construct(Application::APP_ID, $request);

$this->request = $request;
}

private function validateHarpSharedKey(array $metadata = []): bool {
$harpKey = $this->appConfig->getValueString(Application::APP_ID, 'harp_shared_key');
$headerHarpKey = $this->request->getHeader('HARP-SHARED-KEY');
if ($headerHarpKey === '' || $headerHarpKey !== $harpKey) {
$this->logger->error('Harp shared key is not valid');
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), $metadata);
return false;
}
return true;
}

#[PublicPage]
#[NoCSRFRequired]
public function getExAppMetadata(string $appId): DataResponse {
if (!$this->validateHarpSharedKey(['appid' => $appId])) {
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
}

$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null) {
$this->logger->error(sprintf('ExApp with appId %s not found.', $appId));
// Protection for guessing installed ExApps list
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), [
'appid' => $appId,
]);
return new DataResponse(['message' => 'ExApp not found'], Http::STATUS_NOT_FOUND);
}

return new DataResponse(HarpService::getHarpExApp($exApp));
}

protected function isUserEnabled(string $userId): bool {
$user = $this->userManager->get($userId);
if ($user === null) {
$this->logger->debug('User not found', ['userId' => $userId]);
return false;
}

if (!$user->isEnabled()) {
$this->logger->debug('User is not enabled', ['userId' => $userId]);
return false;
}

return true;
}

/**
* access_level:
* 0: PUBLIC
* 1: USER
* 2: ADMIN
* @return DataResponse array{ user_id: string, access_level: int }
*/
#[PublicPage]
#[NoCSRFRequired]
public function getUserInfo(): DataResponse {
if (!$this->validateHarpSharedKey()) {
return new DataResponse(['message' => 'Invalid token'], Http::STATUS_UNAUTHORIZED);
}

if ($this->userId === null) {
$this->logger->debug('No user found in the harp request');
return new DataResponse([
'user_id' => '',
'access_level' => ExAppRouteAccessLevel::PUBLIC->value,
]);
}

if (!$this->isUserEnabled($this->userId)) {
$this->logger->debug('User is not enabled in the harp request', ['userId' => $this->userId]);
return new DataResponse([
'user_id' => $this->userId,
'access_level' => ExAppRouteAccessLevel::PUBLIC->value,
]);
}

if ($this->groupManager->isAdmin($this->userId)) {
return new DataResponse([
'user_id' => $this->userId,
'access_level' => ExAppRouteAccessLevel::ADMIN->value,
]);
}

return new DataResponse([
'user_id' => $this->userId,
'access_level' => ExAppRouteAccessLevel::USER->value,
]);
}
}

enum ExAppRouteAccessLevel: int {
case PUBLIC = 0;
case USER = 1;
case ADMIN = 2;
}
Loading
Loading