Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
53 commits
Select commit Hold shift + click to select a range
ebe4a00
enh: add exapps info endpoint for HaRP
kyteinsky Feb 7, 2025
6604b9e
get metadata for only one ex-app
kyteinsky Feb 7, 2025
4b38bc2
add bruteforce throttler
kyteinsky Feb 7, 2025
e409bdb
enh: add user info endpoint for HaRP
kyteinsky Feb 10, 2025
c3c2cd5
enh: add user info endpoint for HaRP
kyteinsky Feb 12, 2025
0ae2f24
fixes
kyteinsky Feb 13, 2025
8b8dc22
adjustments
kyteinsky Feb 13, 2025
5884ab7
occ: make daemon commands harp combatible
kyteinsky Feb 14, 2025
f21b930
connect to internal dsp of harp if present
kyteinsky Feb 14, 2025
15881ab
ex-app harp specific env set
kyteinsky Feb 14, 2025
8618103
update exapp cache in harp
kyteinsky Feb 21, 2025
fdeae77
frp cert copy from harp to exapp
kyteinsky Feb 21, 2025
28127f3
ui: add harp daemon config
kyteinsky Feb 24, 2025
88d3567
move haproxy password out of foldable
kyteinsky Feb 24, 2025
3ede37f
change harp deploy config structure
kyteinsky Feb 24, 2025
ebab47d
use harp shared key from daemon config
kyteinsky Feb 25, 2025
9608349
wip: improvements to daemon registration modal
kyteinsky Feb 25, 2025
f3c1b5c
ui second draft
kyteinsky Feb 27, 2025
e9516ca
final fixes
kyteinsky Feb 27, 2025
db1c359
little ui changes
kyteinsky Feb 28, 2025
4c5b308
connect to exapps through harp
kyteinsky Feb 28, 2025
dde05a0
compiled assets
kyteinsky Feb 28, 2025
6ab9a36
psalm fixes
kyteinsky Feb 28, 2025
4d55d0a
exapp always binds to localhost for harp
kyteinsky Feb 28, 2025
f6fcd8a
fix register daemon isHarp option
kyteinsky Feb 28, 2025
bb91d7d
remove https checkbox + change def shared key
kyteinsky Mar 3, 2025
0f0fc11
update daemon reg command help
kyteinsky Mar 3, 2025
b6de419
chore(assets): Recompile assets
nextcloud-command Mar 6, 2025
bb25682
ci: harp deploy tests
kyteinsky Mar 5, 2025
ea8a83c
Merge branch 'main' into enh/harp/exapp-info-endpoint
kyteinsky Mar 6, 2025
57d26dc
fix reuse
kyteinsky Mar 6, 2025
f406fe4
ci: fix harp deploy tests
kyteinsky Mar 6, 2025
7bfd140
test
kyteinsky Mar 6, 2025
d7b9ede
REMOVE L8R: faster test iteration
kyteinsky Mar 6, 2025
8020ecf
fast fix ci
kyteinsky Mar 6, 2025
058dd67
debug ci
oleksandr-nc Mar 6, 2025
9bca87c
fix: remove suffix /index.php from exapp url
kyteinsky Mar 6, 2025
402dcd3
ci fix
kyteinsky Mar 6, 2025
8d8db26
add resolver in nginx conf
kyteinsky Mar 6, 2025
fee9809
add other two tests
kyteinsky Mar 6, 2025
a937d91
Revert "REMOVE L8R: faster test iteration"
kyteinsky Mar 6, 2025
aa325fd
require harp tests
kyteinsky Mar 6, 2025
53fcce3
allow manual-install to use harp
kyteinsky Mar 21, 2025
02b4486
fix: early error in appinfo json parsing
kyteinsky Mar 24, 2025
46371dc
use deploy config from exapp instead of daemon config
kyteinsky Mar 24, 2025
2f6fc74
ci: manual harp deploy test
kyteinsky Mar 24, 2025
c2a6430
chore(assets): Recompile assets
nextcloud-command Mar 25, 2025
d8adb83
feat: default daemon setup checks
kyteinsky Mar 25, 2025
39239d0
fix enable harp toggle
kyteinsky Mar 27, 2025
9ef202f
edit deploy daemon and manual-install fixes
kyteinsky Mar 27, 2025
4e64578
chore(assets): Recompile assets
nextcloud-command Mar 27, 2025
bfb3d3d
do not fetch exapp twice for apps metadata
kyteinsky Mar 31, 2025
272c54b
Merge branch 'main' into enh/harp/exapp-info-endpoint
oleksandr-nc Apr 1, 2025
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
501 changes: 500 additions & 1 deletion .github/workflows/tests-deploy.yml

Large diffs are not rendered by default.

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
2 changes: 1 addition & 1 deletion js/app_api-adminSettings.js

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion js/app_api-adminSettings.js.map

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions lib/AppInfo/Application.php
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@
use OCA\AppAPI\PublicCapabilities;
use OCA\AppAPI\Service\ProvidersAI\TaskProcessingService;
use OCA\AppAPI\Service\UI\TopMenuService;
use OCA\AppAPI\SetupChecks\DaemonCheck;
use OCA\DAV\Events\SabrePluginAuthInitEvent;
use OCA\Files\Event\LoadAdditionalScriptsEvent;
use OCP\AppFramework\App;
Expand Down Expand Up @@ -87,6 +88,8 @@ public function register(IRegistrationContext $context): void {
$context->registerEventListener(NodeDeletedEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeRenamedEvent::class, FileEventsListener::class);
$context->registerEventListener(NodeCopiedEvent::class, FileEventsListener::class);

$context->registerSetupCheck(DaemonCheck::class);
}

public function boot(IBootContext $context): void {
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'],
isset($daemon->getDeployConfig()['harp']) ? 'yes' : 'no',
$daemon->getDeployConfig()['harp']['frp_address'] ?? '(none)',
$daemon->getDeployConfig()['harp']['docker_socket_port'] ?? '(none)',
];
}

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

$this->addArgument('name', InputArgument::REQUIRED);
$this->addArgument('name', InputArgument::REQUIRED, 'Unique deploy daemon name');
$this->addArgument('display-name', InputArgument::REQUIRED);
$this->addArgument('accepts-deploy-id', InputArgument::REQUIRED);
$this->addArgument('protocol', InputArgument::REQUIRED);
$this->addArgument('host', InputArgument::REQUIRED);
$this->addArgument('accepts-deploy-id', InputArgument::REQUIRED, 'The deployment method that the daemon accepts. Can be "manual-install" or "docker-install". "docker-install" is for Docker Socket Proxy and HaRP.');
$this->addArgument('protocol', InputArgument::REQUIRED, 'The protocol used to connect to the daemon. Can be "http" or "https".');
$this->addArgument('host', InputArgument::REQUIRED, 'The hostname (and port) or path at which the docker socket proxy or harp or the manual-install app is/would be available. This need not be a public host, just a host accessible by the Nextcloud server. It can also be a path to the docker socket. (e.g. appapi-harp:8780, /var/run/docker.sock)');
$this->addArgument('nextcloud_url', InputArgument::REQUIRED);

// 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('net', null, InputOption::VALUE_REQUIRED, 'The name of the docker network the ex-apps installed by this daemon should use. Default is "host".');
$this->addOption('haproxy_password', null, InputOption::VALUE_REQUIRED, 'AppAPI Docker Socket Proxy password for HAProxy Basic auth. Only for docker socket proxy daemon.');
$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('harp_proxy_docker "Harp Proxy (Docker)" "docker-install" "http" "appapi-harp:8780" "http://nextcloud.local" --net nextcloud --harp --harp_frp_address "appapi-harp:8782" --harp_shared_key "some_very_secure_password" --set-default --compute_device=cuda');
$this->addUsage('harp_proxy_host "Harp Proxy (Host)" "docker-install" "http" "localhost:8780" "http://nextcloud.local" --harp --harp_frp_address "localhost:8782" --harp_shared_key "some_very_secure_password" --set-default --compute_device=cuda');
$this->addUsage('manual_install_harp "Harp Manual Install" "manual-install" "http" "appapi-harp:8780" "http://nextcloud.local" --net nextcloud --harp --harp_frp_address "appapi-harp:8782" --harp_shared_key "some_very_secure_password"');
$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('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');
}

protected function execute(InputInterface $input, OutputInterface $output): int {
Expand All @@ -58,28 +65,48 @@ 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');

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;
}
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' => null,
];
if ($isHarp) {
$deployConfig['harp'] = [
'frp_address' => $input->getOption('harp_frp_address') ?? '',
'docker_socket_port' => $input->getOption('harp_docker_socket_port'),
];
}

$daemonConfig = $this->daemonConfigService->registerDaemonConfig([
'name' => $name,
'display_name' => $displayName,
Expand Down
10 changes: 0 additions & 10 deletions lib/Controller/DaemonConfigController.php
Original file line number Diff line number Diff line change
Expand Up @@ -121,11 +121,6 @@ public function unregisterDaemonConfig(string $name): Response {

public function verifyDaemonConnection(string $name): Response {
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
if ($daemonConfig->getAcceptsDeployId() !== $this->dockerActions->getAcceptsDeployId()) {
return new JSONResponse([
'error' => sprintf('Only "%s" is supported', $this->dockerActions->getAcceptsDeployId()),
]);
}
$this->dockerActions->initGuzzleClient($daemonConfig);
$dockerDaemonAccessible = $this->dockerActions->ping($this->dockerActions->buildDockerUrl($daemonConfig));
return new JSONResponse([
Expand Down Expand Up @@ -165,11 +160,6 @@ public function checkDaemonConnection(array $daemonParams): Response {
'host' => $daemonParams['host'],
'deploy_config' => $daemonParams['deploy_config'],
]);
if ($daemonConfig->getAcceptsDeployId() !== $this->dockerActions->getAcceptsDeployId()) {
return new JSONResponse([
'error' => sprintf('Only "%s" is supported', $this->dockerActions->getAcceptsDeployId()),
]);
}
$this->dockerActions->initGuzzleClient($daemonConfig);
$dockerDaemonAccessible = $this->dockerActions->ping($this->dockerActions->buildDockerUrl($daemonConfig));
return new JSONResponse([
Expand Down
168 changes: 168 additions & 0 deletions lib/Controller/HarpController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
<?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\Db\ExApp;
use OCA\AppAPI\Service\DaemonConfigService;
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 OCP\Security\ICrypto;
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 DaemonConfigService $daemonConfigService,
private readonly ICrypto $crypto,
private readonly ?string $userId,
) {
parent::__construct(Application::APP_ID, $request);

$this->request = $request;
}

private function validateHarpSharedKey(ExApp $exApp): bool {
try {
if (!isset($exApp->getDeployConfig()['haproxy_password'])) {
$this->logger->error('Harp shared key is not set. Invalid daemon config.');
return false;
}
$harpKey = $this->crypto->decrypt($exApp->getDeployConfig()['haproxy_password']);
} catch (\Exception $e) {
$this->logger->error('Failed to decrypt harp shared key. Invalid daemon config.', ['exception' => $e]);
return false;
}

$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(), [
'appid' => $exApp->getAppid(),
]);
return false;
}
return true;
}

#[PublicPage]
#[NoCSRFRequired]
public function getExAppMetadata(string $appId): DataResponse {
$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null) {
$this->logger->error('ExApp not found', ['appId' => $appId]);
// Protection for guessing installed ExApps list
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), [
'appid' => $appId,
]);
// return the same response as invalid harp key to prevent ex-app guessing
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
}

if (!$this->validateHarpSharedKey($exApp)) {
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
}

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(string $appId): DataResponse {
$exApp = $this->exAppService->getExApp($appId);
if ($exApp === null) {
$this->logger->error('ExApp not found', ['appId' => $appId]);
// Protection for guessing installed ExApps list
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), [
'appid' => $appId,
]);
// return the same response as invalid harp key to prevent ex-app guessing
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
}

if (!$this->validateHarpSharedKey($exApp)) {
return new DataResponse(['message' => 'Harp shared key is not valid'], 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