Skip to content

Commit 3d332ea

Browse files
kyteinskynextcloud-commandoleksandr-nc
authored
HaRP support (#505)
This PR add support for HaRP - the successor of DockerSocketProxy. --------- Signed-off-by: Anupam Kumar <[email protected]> Signed-off-by: nextcloud-command <[email protected]> Signed-off-by: Oleksander Piskun <[email protected]> Co-authored-by: nextcloud-command <[email protected]> Co-authored-by: Oleksander Piskun <[email protected]>
1 parent d1280fc commit 3d332ea

22 files changed

+1569
-208
lines changed

.github/workflows/tests-deploy.yml

Lines changed: 500 additions & 1 deletion
Large diffs are not rendered by default.

appinfo/routes.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@
5858
['name' => 'DaemonConfig#startTestDeploy', 'url' => '/daemons/{name}/test_deploy', 'verb' => 'POST'],
5959
['name' => 'DaemonConfig#stopTestDeploy', 'url' => '/daemons/{name}/test_deploy', 'verb' => 'DELETE'],
6060
['name' => 'DaemonConfig#getTestDeployStatus', 'url' => '/daemons/{name}/test_deploy/status', 'verb' => 'GET'],
61+
62+
// HaRP actions
63+
['name' => 'Harp#getExAppMetadata', 'url' => '/harp/exapp-meta', 'verb' => 'GET'],
64+
['name' => 'Harp#getUserInfo', 'url' => '/harp/user-info', 'verb' => 'GET'],
6165
],
6266
'ocs' => [
6367
// Logging

js/app_api-adminSettings.js

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/app_api-adminSettings.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

lib/AppInfo/Application.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
use OCA\AppAPI\PublicCapabilities;
2525
use OCA\AppAPI\Service\ProvidersAI\TaskProcessingService;
2626
use OCA\AppAPI\Service\UI\TopMenuService;
27+
use OCA\AppAPI\SetupChecks\DaemonCheck;
2728
use OCA\DAV\Events\SabrePluginAuthInitEvent;
2829
use OCA\Files\Event\LoadAdditionalScriptsEvent;
2930
use OCP\AppFramework\App;
@@ -87,6 +88,8 @@ public function register(IRegistrationContext $context): void {
8788
$context->registerEventListener(NodeDeletedEvent::class, FileEventsListener::class);
8889
$context->registerEventListener(NodeRenamedEvent::class, FileEventsListener::class);
8990
$context->registerEventListener(NodeCopiedEvent::class, FileEventsListener::class);
91+
92+
$context->registerSetupCheck(DaemonCheck::class);
9093
}
9194

9295
public function boot(IBootContext $context): void {

lib/Command/Daemon/ListDaemons.php

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,7 +43,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int
4343

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

4949
foreach ($daemonConfigs as $daemon) {
@@ -53,7 +53,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5353
$daemon->getAcceptsDeployId(),
5454
$daemon->getProtocol(),
5555
$daemon->getHost(),
56-
$daemon->getDeployConfig()['nextcloud_url']
56+
$daemon->getDeployConfig()['nextcloud_url'],
57+
isset($daemon->getDeployConfig()['harp']) ? 'yes' : 'no',
58+
$daemon->getDeployConfig()['harp']['frp_address'] ?? '(none)',
59+
$daemon->getDeployConfig()['harp']['docker_socket_port'] ?? '(none)',
5760
];
5861
}
5962

lib/Command/Daemon/RegisterDaemon.php

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -32,23 +32,30 @@ protected function configure(): void {
3232
$this->setName('app_api:daemon:register');
3333
$this->setDescription('Register daemon config for ExApp deployment');
3434

35-
$this->addArgument('name', InputArgument::REQUIRED);
35+
$this->addArgument('name', InputArgument::REQUIRED, 'Unique deploy daemon name');
3636
$this->addArgument('display-name', InputArgument::REQUIRED);
37-
$this->addArgument('accepts-deploy-id', InputArgument::REQUIRED);
38-
$this->addArgument('protocol', InputArgument::REQUIRED);
39-
$this->addArgument('host', InputArgument::REQUIRED);
37+
$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.');
38+
$this->addArgument('protocol', InputArgument::REQUIRED, 'The protocol used to connect to the daemon. Can be "http" or "https".');
39+
$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)');
4040
$this->addArgument('nextcloud_url', InputArgument::REQUIRED);
4141

4242
// daemon-config settings
43-
$this->addOption('net', null, InputOption::VALUE_REQUIRED, 'DeployConfig, the name of the docker network to attach App to');
44-
$this->addOption('haproxy_password', null, InputOption::VALUE_REQUIRED, 'AppAPI Docker Socket Proxy password for HAProxy Basic auth');
45-
43+
$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".');
44+
$this->addOption('haproxy_password', null, InputOption::VALUE_REQUIRED, 'AppAPI Docker Socket Proxy password for HAProxy Basic auth. Only for docker socket proxy daemon.');
4645
$this->addOption('compute_device', null, InputOption::VALUE_REQUIRED, 'Compute device for GPU support (cpu|cuda|rocm)');
47-
4846
$this->addOption('set-default', null, InputOption::VALUE_NONE, 'Set DaemonConfig as default');
49-
50-
$this->addUsage('local_docker "Docker local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud');
51-
$this->addUsage('local_docker "Docker local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
47+
$this->addOption('harp', null, InputOption::VALUE_NONE, 'Set daemon to use HaRP for all docker and exapp communication');
48+
$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');
49+
$this->addOption('harp_shared_key', null, InputOption::VALUE_REQUIRED, 'HaRP shared key for secure communication between HaRP and AppAPI');
50+
$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');
51+
52+
$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');
53+
$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');
54+
$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"');
55+
$this->addUsage('docker_install "Docker Socket Proxy" "docker-install" "http" "nextcloud-appapi-dsp:2375" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
56+
$this->addUsage('manual_install "Manual Install" "manual-install" "http" null "http://nextcloud.local"');
57+
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud');
58+
$this->addUsage('local_docker "Docker Local" "docker-install" "http" "/var/run/docker.sock" "http://nextcloud.local" --net=nextcloud --set-default --compute_device=cuda');
5259
}
5360

5461
protected function execute(InputInterface $input, OutputInterface $output): int {
@@ -58,28 +65,48 @@ protected function execute(InputInterface $input, OutputInterface $output): int
5865
$protocol = $input->getArgument('protocol');
5966
$host = $input->getArgument('host');
6067
$nextcloudUrl = $input->getArgument('nextcloud_url');
61-
62-
$deployConfig = [
63-
'net' => $input->getOption('net') ?? 'host',
64-
'nextcloud_url' => $nextcloudUrl,
65-
'haproxy_password' => $input->getOption('haproxy_password') ?? '',
66-
'computeDevice' => $this->buildComputeDevice($input->getOption('compute_device') ?? 'cpu'),
67-
];
68+
$isHarp = $input->getOption('harp');
6869

6970
if (($protocol !== 'http') && ($protocol !== 'https')) {
7071
$output->writeln('Value error: The protocol must be `http` or `https`.');
7172
return 1;
7273
}
73-
if (($acceptsDeployId === 'manual-install') && ($protocol !== 'http')) {
74+
if ($acceptsDeployId === 'manual-install' && $protocol !== 'http') {
7475
$output->writeln('Value error: Manual-install daemon supports only `http` protocol.');
7576
return 1;
7677
}
78+
if ($isHarp && !$input->getOption('harp_shared_key')) {
79+
$output->writeln('Value error: HaRP enabled daemon requires `harp_shared_key` option.');
80+
return 1;
81+
}
82+
if ($isHarp && !$input->getOption('harp_frp_address')) {
83+
$output->writeln('Value error: HaRP enabled daemon requires `harp_frp_address` option.');
84+
return 1;
85+
}
7786

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

92+
$secret = $isHarp
93+
? $input->getOption('harp_shared_key')
94+
: $input->getOption('haproxy_password') ?? '';
95+
96+
$deployConfig = [
97+
'net' => $input->getOption('net') ?? 'host',
98+
'nextcloud_url' => $nextcloudUrl,
99+
'haproxy_password' => $secret,
100+
'computeDevice' => $this->buildComputeDevice($input->getOption('compute_device') ?? 'cpu'),
101+
'harp' => null,
102+
];
103+
if ($isHarp) {
104+
$deployConfig['harp'] = [
105+
'frp_address' => $input->getOption('harp_frp_address') ?? '',
106+
'docker_socket_port' => $input->getOption('harp_docker_socket_port'),
107+
];
108+
}
109+
83110
$daemonConfig = $this->daemonConfigService->registerDaemonConfig([
84111
'name' => $name,
85112
'display_name' => $displayName,

lib/Controller/DaemonConfigController.php

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -121,11 +121,6 @@ public function unregisterDaemonConfig(string $name): Response {
121121

122122
public function verifyDaemonConnection(string $name): Response {
123123
$daemonConfig = $this->daemonConfigService->getDaemonConfigByName($name);
124-
if ($daemonConfig->getAcceptsDeployId() !== $this->dockerActions->getAcceptsDeployId()) {
125-
return new JSONResponse([
126-
'error' => sprintf('Only "%s" is supported', $this->dockerActions->getAcceptsDeployId()),
127-
]);
128-
}
129124
$this->dockerActions->initGuzzleClient($daemonConfig);
130125
$dockerDaemonAccessible = $this->dockerActions->ping($this->dockerActions->buildDockerUrl($daemonConfig));
131126
return new JSONResponse([
@@ -165,11 +160,6 @@ public function checkDaemonConnection(array $daemonParams): Response {
165160
'host' => $daemonParams['host'],
166161
'deploy_config' => $daemonParams['deploy_config'],
167162
]);
168-
if ($daemonConfig->getAcceptsDeployId() !== $this->dockerActions->getAcceptsDeployId()) {
169-
return new JSONResponse([
170-
'error' => sprintf('Only "%s" is supported', $this->dockerActions->getAcceptsDeployId()),
171-
]);
172-
}
173163
$this->dockerActions->initGuzzleClient($daemonConfig);
174164
$dockerDaemonAccessible = $this->dockerActions->ping($this->dockerActions->buildDockerUrl($daemonConfig));
175165
return new JSONResponse([

lib/Controller/HarpController.php

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/**
6+
* SPDX-FileCopyrightText: 2023 Nextcloud GmbH and Nextcloud contributors
7+
* SPDX-License-Identifier: AGPL-3.0-or-later
8+
*/
9+
10+
namespace OCA\AppAPI\Controller;
11+
12+
use OCA\AppAPI\AppInfo\Application;
13+
use OCA\AppAPI\Db\ExApp;
14+
use OCA\AppAPI\Service\DaemonConfigService;
15+
use OCA\AppAPI\Service\ExAppService;
16+
use OCA\AppAPI\Service\HarpService;
17+
use OCP\AppFramework\Controller;
18+
use OCP\AppFramework\Http;
19+
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
20+
use OCP\AppFramework\Http\Attribute\PublicPage;
21+
use OCP\AppFramework\Http\DataResponse;
22+
use OCP\IAppConfig;
23+
use OCP\IGroupManager;
24+
use OCP\IRequest;
25+
use OCP\IUserManager;
26+
use OCP\Security\Bruteforce\IThrottler;
27+
use OCP\Security\ICrypto;
28+
use Psr\Log\LoggerInterface;
29+
30+
class HarpController extends Controller {
31+
protected $request;
32+
33+
public function __construct(
34+
IRequest $request,
35+
private readonly IAppConfig $appConfig,
36+
private readonly ExAppService $exAppService,
37+
private readonly LoggerInterface $logger,
38+
private readonly IThrottler $throttler,
39+
private readonly IUserManager $userManager,
40+
private readonly IGroupManager $groupManager,
41+
private readonly DaemonConfigService $daemonConfigService,
42+
private readonly ICrypto $crypto,
43+
private readonly ?string $userId,
44+
) {
45+
parent::__construct(Application::APP_ID, $request);
46+
47+
$this->request = $request;
48+
}
49+
50+
private function validateHarpSharedKey(ExApp $exApp): bool {
51+
try {
52+
if (!isset($exApp->getDeployConfig()['haproxy_password'])) {
53+
$this->logger->error('Harp shared key is not set. Invalid daemon config.');
54+
return false;
55+
}
56+
$harpKey = $this->crypto->decrypt($exApp->getDeployConfig()['haproxy_password']);
57+
} catch (\Exception $e) {
58+
$this->logger->error('Failed to decrypt harp shared key. Invalid daemon config.', ['exception' => $e]);
59+
return false;
60+
}
61+
62+
$headerHarpKey = $this->request->getHeader('HARP-SHARED-KEY');
63+
if ($headerHarpKey === '' || $headerHarpKey !== $harpKey) {
64+
$this->logger->error('Harp shared key is not valid');
65+
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), [
66+
'appid' => $exApp->getAppid(),
67+
]);
68+
return false;
69+
}
70+
return true;
71+
}
72+
73+
#[PublicPage]
74+
#[NoCSRFRequired]
75+
public function getExAppMetadata(string $appId): DataResponse {
76+
$exApp = $this->exAppService->getExApp($appId);
77+
if ($exApp === null) {
78+
$this->logger->error('ExApp not found', ['appId' => $appId]);
79+
// Protection for guessing installed ExApps list
80+
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), [
81+
'appid' => $appId,
82+
]);
83+
// return the same response as invalid harp key to prevent ex-app guessing
84+
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
85+
}
86+
87+
if (!$this->validateHarpSharedKey($exApp)) {
88+
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
89+
}
90+
91+
return new DataResponse(HarpService::getHarpExApp($exApp));
92+
}
93+
94+
protected function isUserEnabled(string $userId): bool {
95+
$user = $this->userManager->get($userId);
96+
if ($user === null) {
97+
$this->logger->debug('User not found', ['userId' => $userId]);
98+
return false;
99+
}
100+
101+
if (!$user->isEnabled()) {
102+
$this->logger->debug('User is not enabled', ['userId' => $userId]);
103+
return false;
104+
}
105+
106+
return true;
107+
}
108+
109+
/**
110+
* access_level:
111+
* 0: PUBLIC
112+
* 1: USER
113+
* 2: ADMIN
114+
* @return DataResponse array{ user_id: string, access_level: int }
115+
*/
116+
#[PublicPage]
117+
#[NoCSRFRequired]
118+
public function getUserInfo(string $appId): DataResponse {
119+
$exApp = $this->exAppService->getExApp($appId);
120+
if ($exApp === null) {
121+
$this->logger->error('ExApp not found', ['appId' => $appId]);
122+
// Protection for guessing installed ExApps list
123+
$this->throttler->registerAttempt(Application::APP_ID, $this->request->getRemoteAddress(), [
124+
'appid' => $appId,
125+
]);
126+
// return the same response as invalid harp key to prevent ex-app guessing
127+
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
128+
}
129+
130+
if (!$this->validateHarpSharedKey($exApp)) {
131+
return new DataResponse(['message' => 'Harp shared key is not valid'], Http::STATUS_UNAUTHORIZED);
132+
}
133+
134+
if ($this->userId === null) {
135+
$this->logger->debug('No user found in the harp request');
136+
return new DataResponse([
137+
'user_id' => '',
138+
'access_level' => ExAppRouteAccessLevel::PUBLIC->value,
139+
]);
140+
}
141+
142+
if (!$this->isUserEnabled($this->userId)) {
143+
$this->logger->debug('User is not enabled in the harp request', ['userId' => $this->userId]);
144+
return new DataResponse([
145+
'user_id' => $this->userId,
146+
'access_level' => ExAppRouteAccessLevel::PUBLIC->value,
147+
]);
148+
}
149+
150+
if ($this->groupManager->isAdmin($this->userId)) {
151+
return new DataResponse([
152+
'user_id' => $this->userId,
153+
'access_level' => ExAppRouteAccessLevel::ADMIN->value,
154+
]);
155+
}
156+
157+
return new DataResponse([
158+
'user_id' => $this->userId,
159+
'access_level' => ExAppRouteAccessLevel::USER->value,
160+
]);
161+
}
162+
}
163+
164+
enum ExAppRouteAccessLevel: int {
165+
case PUBLIC = 0;
166+
case USER = 1;
167+
case ADMIN = 2;
168+
}

0 commit comments

Comments
 (0)