Secure first-loop control-plane auth and mount routing.

Protect the control-plane API with explicit bearer auth, add node-scoped
registration/heartbeat credentials, and make export mount paths an explicit
contract field so mount profiles stay correct across runtimes.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
This commit is contained in:
Harivansh Rathi 2026-04-01 14:13:14 +00:00
parent a7f85f4871
commit ed40da7326
23 changed files with 3676 additions and 124 deletions

View file

@ -23,8 +23,8 @@ class ControlPlaneClient {
$baseUrl = $this->controlPlaneConfig->getBaseUrl();
try {
$healthResponse = $this->request($baseUrl . '/health');
$versionResponse = $this->request($baseUrl . '/version');
$healthResponse = $this->requestObject($baseUrl . '/health');
$versionResponse = $this->requestObject($baseUrl . '/version');
return [
'available' => $healthResponse['statusCode'] === 200,
@ -46,32 +46,88 @@ class ControlPlaneClient {
}
}
/**
* @return array<string, mixed>|null
*/
public function fetchExport(string $exportId): ?array {
$baseUrl = $this->controlPlaneConfig->getBaseUrl();
try {
$exportsResponse = $this->requestList($baseUrl . '/api/v1/exports', true);
} catch (\Throwable $exception) {
$this->logger->warning('Failed to fetch betterNAS exports', [
'exception' => $exception,
'url' => $baseUrl,
'exportId' => $exportId,
]);
return null;
}
foreach ($exportsResponse['body'] as $export) {
if (!is_array($export)) {
continue;
}
if (($export['id'] ?? null) === $exportId) {
return $export;
}
}
return null;
}
/**
* @return array{statusCode: int, body: array<string, mixed>}
*/
private function request(string $url): array {
private function requestObject(string $url, bool $authenticated = false): array {
$response = $this->request($url, $authenticated);
return [
'statusCode' => $response->getStatusCode(),
'body' => $this->decodeObjectBody($response),
];
}
/**
* @return array{statusCode: int, body: array<int, array<string, mixed>>}
*/
private function requestList(string $url, bool $authenticated = false): array {
$response = $this->request($url, $authenticated);
return [
'statusCode' => $response->getStatusCode(),
'body' => $this->decodeListBody($response),
];
}
private function request(string $url, bool $authenticated = false): IResponse {
$headers = [
'Accept' => 'application/json',
];
if ($authenticated) {
$apiToken = $this->controlPlaneConfig->getApiToken();
if ($apiToken === '') {
throw new \RuntimeException('Missing betterNAS control plane API token');
}
$headers['Authorization'] = 'Bearer ' . $apiToken;
}
$client = $this->clientService->newClient();
$response = $client->get($url, [
'headers' => [
'Accept' => 'application/json',
],
return $client->get($url, [
'headers' => $headers,
'http_errors' => false,
'timeout' => 2,
'nextcloud' => [
'allow_local_address' => true,
],
]);
return [
'statusCode' => $response->getStatusCode(),
'body' => $this->decodeBody($response),
];
}
/**
* @return array<string, mixed>
*/
private function decodeBody(IResponse $response): array {
private function decodeObjectBody(IResponse $response): array {
$body = $response->getBody();
if ($body === '') {
return [];
@ -84,5 +140,29 @@ class ControlPlaneClient {
return $decoded;
}
}
/**
* @return array<int, array<string, mixed>>
*/
private function decodeListBody(IResponse $response): array {
$body = $response->getBody();
if ($body === '') {
return [];
}
$decoded = json_decode($body, true, 512, JSON_THROW_ON_ERROR);
if (!is_array($decoded)) {
return [];
}
$exports = [];
foreach ($decoded as $export) {
if (!is_array($export)) {
continue;
}
$exports[] = $export;
}
return $exports;
}
}