2026-06-20 07:23:16 +00:00
|
|
|
<?php
|
|
|
|
|
|
|
|
|
|
namespace App\Services\Flux;
|
|
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
use App\Exceptions\V5\UnsupportedCooldVerb;
|
2026-06-20 07:23:16 +00:00
|
|
|
use Illuminate\Support\Str;
|
|
|
|
|
use RuntimeException;
|
|
|
|
|
|
|
|
|
|
class FluxClient
|
|
|
|
|
{
|
|
|
|
|
/**
|
|
|
|
|
* @return array<int, array{id?: string, name?: string, image?: string, state?: string, networks?: array<int, string>}>
|
|
|
|
|
*/
|
|
|
|
|
public function listContainers(string $hostId): array
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
2026-06-21 14:54:02 +00:00
|
|
|
'type' => 'containers.list',
|
2026-06-20 07:23:16 +00:00
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$data = $payload['data'] ?? [];
|
|
|
|
|
|
|
|
|
|
return is_array($data) ? $data : [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 20:39:16 +00:00
|
|
|
public function pullImage(string $hostId, string $image): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'images.pull',
|
|
|
|
|
'reference' => $image,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'Image pulled.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $spec
|
|
|
|
|
*/
|
|
|
|
|
public function createContainer(string $hostId, array $spec): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'containers.create',
|
|
|
|
|
...$spec,
|
|
|
|
|
]);
|
|
|
|
|
$data = $payload['data'] ?? [];
|
|
|
|
|
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
|
|
|
|
|
|
|
|
|
|
if ($id === '') {
|
|
|
|
|
throw new RuntimeException('Flux did not return a container id.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function startContainer(string $hostId, string $id): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'containers.start',
|
|
|
|
|
'id' => $id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'Container started.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
public function stopContainer(string $hostId, string $id, int $timeoutSeconds = 10): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'containers.stop',
|
|
|
|
|
'id' => $id,
|
|
|
|
|
'timeout_seconds' => max(0, $timeoutSeconds),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'Container stopped.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function removeContainer(string $hostId, string $id, bool $force = false): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'containers.delete',
|
|
|
|
|
'id' => $id,
|
|
|
|
|
'force' => $force,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'Container removed.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-21 20:39:16 +00:00
|
|
|
/**
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
*/
|
|
|
|
|
public function inspectContainer(string $hostId, string $id): array
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'containers.inspect',
|
|
|
|
|
'id' => $id,
|
|
|
|
|
]);
|
|
|
|
|
$data = $payload['data'] ?? [];
|
|
|
|
|
|
|
|
|
|
return is_array($data) ? $data : [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 13:08:45 +00:00
|
|
|
/**
|
2026-06-21 16:58:01 +00:00
|
|
|
* @param array<int, array{name: string, config: string}> $apps
|
2026-06-20 13:08:45 +00:00
|
|
|
*/
|
2026-06-21 16:58:01 +00:00
|
|
|
public function applyIngress(string $hostId, string $kind, string $config, array $apps = [], string $meshNetwork = 'coolify-default-mesh'): string
|
2026-06-20 13:08:45 +00:00
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
2026-06-21 16:58:01 +00:00
|
|
|
'type' => 'ingress.apply',
|
|
|
|
|
'kind' => $kind,
|
|
|
|
|
'config' => $config,
|
2026-06-20 13:08:45 +00:00
|
|
|
'apps' => $apps,
|
|
|
|
|
'mesh_network' => $meshNetwork,
|
|
|
|
|
]);
|
|
|
|
|
|
2026-06-21 16:58:01 +00:00
|
|
|
return $this->output($payload, 'Ingress applied.');
|
2026-06-20 13:08:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 16:58:01 +00:00
|
|
|
public function stopIngress(string $hostId, string $kind): string
|
2026-06-20 13:08:45 +00:00
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
2026-06-21 16:58:01 +00:00
|
|
|
'type' => 'ingress.stop',
|
|
|
|
|
'kind' => $kind,
|
2026-06-20 13:08:45 +00:00
|
|
|
]);
|
|
|
|
|
|
2026-06-21 16:58:01 +00:00
|
|
|
return $this->output($payload, 'Ingress stopped.');
|
2026-06-20 13:08:45 +00:00
|
|
|
}
|
|
|
|
|
|
2026-06-21 19:16:08 +00:00
|
|
|
/**
|
|
|
|
|
* @param array{id: string, namespace: string, src: string, dst: string, proto: string, port: int} $rule
|
|
|
|
|
*/
|
|
|
|
|
public function applyFirewallRule(string $hostId, array $rule): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'firewall.allow',
|
|
|
|
|
...$rule,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$data = $payload['data'] ?? [];
|
|
|
|
|
$id = is_array($data) && is_string($data['id'] ?? null) ? $data['id'] : '';
|
|
|
|
|
|
|
|
|
|
return $id !== '' ? $id : $this->output($payload, 'Firewall rule applied.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
public function revokeFirewallRule(string $hostId, string $id): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'firewall.revoke',
|
|
|
|
|
'id' => $id,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'Firewall rule removed.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 11:52:17 +00:00
|
|
|
/**
|
|
|
|
|
* @return array<int, array{id?: string, namespace?: string, src?: string, dst?: string, proto?: string, port?: int}>
|
|
|
|
|
*/
|
|
|
|
|
public function listFirewallRules(string $hostId, string $namespace = ''): array
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'firewall.list',
|
|
|
|
|
'namespace' => $namespace,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
$data = $payload['data'] ?? [];
|
|
|
|
|
|
|
|
|
|
return is_array($data) ? $data : [];
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 09:14:13 +00:00
|
|
|
public function cooldLogs(string $hostId, int $tail = 200): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'coold.logs',
|
|
|
|
|
'tail' => max(1, min($tail, 1000)),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'No coold logs returned.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
public function containerLogs(string $hostId, string $containerId, int $tail = 200): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'containers.logs',
|
|
|
|
|
'id' => $containerId,
|
|
|
|
|
'tail' => max(1, min($tail, 1000)),
|
|
|
|
|
'stdout' => true,
|
|
|
|
|
'stderr' => true,
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, 'No container logs returned.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-22 11:52:17 +00:00
|
|
|
public function corrosionTables(string $hostId, int $limit = 200): string
|
|
|
|
|
{
|
|
|
|
|
$payload = $this->dispatch($hostId, [
|
|
|
|
|
'type' => 'corrosion.tables',
|
|
|
|
|
'limit' => max(1, min($limit, 1000)),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return $this->output($payload, '{"limit":200,"tables":[]}');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
/**
|
|
|
|
|
* Deliver a freshly minted host JWT to the node over the live coold RPC
|
|
|
|
|
* stream (flux gates the `host.jwt.set` capability; the token must carry
|
|
|
|
|
* it). Preferred over the SSH push because it reuses the already
|
|
|
|
|
* authenticated flux<->coold channel and works while the current token is
|
|
|
|
|
* still valid — exactly the rotation window. Throws like the sibling
|
|
|
|
|
* dispatch methods (host not connected / UnsupportedCooldVerb / generic
|
|
|
|
|
* failure) so the caller can catch and fall back to SSH.
|
|
|
|
|
*/
|
|
|
|
|
public function pushHostToken(string $hostId, string $token): void
|
|
|
|
|
{
|
|
|
|
|
$this->dispatch($hostId, [
|
|
|
|
|
'type' => 'host.jwt.set',
|
|
|
|
|
'jwt' => $token,
|
|
|
|
|
]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Revoke a host token by its `jti` on the flux revocation store so flux
|
|
|
|
|
* rejects it at verify immediately, instead of waiting for the token's TTL
|
|
|
|
|
* to lapse (flux/src/unix_bridge.rs `POST /v1/tokens/revoke`,
|
|
|
|
|
* flux/src/auth.rs `is_revoked`). The optional `expiresAt` (the token `exp`,
|
|
|
|
|
* unix seconds) lets flux prune the denylist entry once it can no longer
|
|
|
|
|
* matter.
|
|
|
|
|
*
|
|
|
|
|
* Best-effort like the sibling dispatch methods: throws a RuntimeException on
|
|
|
|
|
* connection failure / timeout / non-2xx so the caller can catch and treat
|
|
|
|
|
* an unreachable flux as non-fatal (the local revocation record still
|
|
|
|
|
* stands and the short TTL + rotation bound the exposure).
|
|
|
|
|
*/
|
|
|
|
|
public function revokeToken(string $jti, ?int $expiresAt = null): void
|
|
|
|
|
{
|
|
|
|
|
if (trim($jti) === '') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$requestBody = ['jti' => $jti];
|
|
|
|
|
|
|
|
|
|
if ($expiresAt !== null) {
|
|
|
|
|
$requestBody['expires_at'] = $expiresAt;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$body = json_encode($requestBody, JSON_THROW_ON_ERROR);
|
|
|
|
|
$response = $this->sendOverSocket('/v1/tokens/revoke', $body);
|
|
|
|
|
$statusCode = $this->statusCode($response);
|
|
|
|
|
|
|
|
|
|
if ($statusCode < 200 || $statusCode >= 300) {
|
|
|
|
|
$responseBody = $this->responseBody($response);
|
|
|
|
|
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
|
|
|
|
|
|
|
|
|
|
throw new RuntimeException(
|
|
|
|
|
$this->errorMessage($payload, $responseBody) ?? "Flux token revocation returned HTTP {$statusCode}."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 07:23:16 +00:00
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $command
|
|
|
|
|
* @return array<string, mixed>
|
|
|
|
|
*/
|
|
|
|
|
private function dispatch(string $hostId, array $command): array
|
2026-07-06 15:40:37 +00:00
|
|
|
{
|
|
|
|
|
$body = json_encode([
|
|
|
|
|
'host_id' => $hostId,
|
|
|
|
|
'request_id' => (string) Str::uuid(),
|
|
|
|
|
'command' => $command,
|
|
|
|
|
], JSON_THROW_ON_ERROR);
|
|
|
|
|
|
|
|
|
|
$response = $this->sendOverSocket('/v1/coold/dispatch', $body);
|
|
|
|
|
|
|
|
|
|
$statusCode = $this->statusCode($response);
|
|
|
|
|
$responseBody = $this->responseBody($response);
|
|
|
|
|
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
|
|
|
|
|
|
|
|
|
|
if ($statusCode < 200 || $statusCode >= 300) {
|
|
|
|
|
throw $this->dispatchException(
|
|
|
|
|
$command,
|
|
|
|
|
$statusCode,
|
|
|
|
|
$this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}."
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! is_array($payload)) {
|
|
|
|
|
throw new RuntimeException('Flux dispatch returned an invalid response.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (($payload['status'] ?? null) === 'error') {
|
|
|
|
|
$message = is_string($payload['message'] ?? null) ? $payload['message'] : 'Flux dispatch failed.';
|
|
|
|
|
|
|
|
|
|
throw $this->dispatchException($command, $statusCode, $message);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return $payload;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Send a single HTTP/1.1 request over the flux Unix-domain socket and return
|
|
|
|
|
* the raw response. Shared by every flux verb (coold dispatch, host token
|
|
|
|
|
* rotation, token revocation) — only the request path and JSON body differ.
|
|
|
|
|
*/
|
|
|
|
|
private function sendOverSocket(string $path, string $body): string
|
2026-06-20 07:23:16 +00:00
|
|
|
{
|
|
|
|
|
$socketPath = config('flux.unix_socket_path');
|
|
|
|
|
|
|
|
|
|
if (! is_string($socketPath) || $socketPath === '') {
|
|
|
|
|
throw new RuntimeException('Flux socket is not configured.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (! file_exists($socketPath)) {
|
|
|
|
|
throw new RuntimeException('Flux socket was not found.');
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:10:48 +00:00
|
|
|
$connectionTimeout = (float) config('flux.connection_timeout_seconds', 1.0);
|
|
|
|
|
$dispatchTimeout = (float) config('flux.dispatch_timeout_seconds', 35.0);
|
|
|
|
|
$stream = @stream_socket_client("unix://{$socketPath}", $errorCode, $errorMessage, $connectionTimeout);
|
2026-06-20 07:23:16 +00:00
|
|
|
|
|
|
|
|
if ($stream === false) {
|
|
|
|
|
throw new RuntimeException($errorMessage ?: "Could not connect to Flux socket ({$errorCode}).");
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 21:10:48 +00:00
|
|
|
stream_set_timeout($stream, (int) ceil($dispatchTimeout));
|
2026-06-20 07:23:16 +00:00
|
|
|
|
|
|
|
|
fwrite($stream, implode("\r\n", [
|
2026-07-06 15:40:37 +00:00
|
|
|
"POST {$path} HTTP/1.1",
|
2026-06-20 07:23:16 +00:00
|
|
|
'Host: flux',
|
|
|
|
|
'Accept: application/json',
|
|
|
|
|
'Content-Type: application/json',
|
|
|
|
|
'Content-Length: '.strlen($body),
|
|
|
|
|
'Connection: close',
|
|
|
|
|
'',
|
|
|
|
|
$body,
|
|
|
|
|
]));
|
|
|
|
|
|
|
|
|
|
$response = stream_get_contents($stream) ?: '';
|
|
|
|
|
fclose($stream);
|
|
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
return $response;
|
|
|
|
|
}
|
2026-06-20 07:23:16 +00:00
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
/**
|
|
|
|
|
* Flux answers a verb the node's coold did not advertise with HTTP 501 and
|
|
|
|
|
* the message "primitive <verb> is not supported by host" (coold repo:
|
|
|
|
|
* flux/src/routing.rs:50-53, flux/src/unix_bridge.rs:227-245). Anything
|
|
|
|
|
* else — including coold-side command failures relayed with their own
|
|
|
|
|
* status code — is a generic dispatch failure.
|
|
|
|
|
*
|
|
|
|
|
* @param array<string, mixed> $command
|
|
|
|
|
*/
|
|
|
|
|
private function dispatchException(array $command, int $statusCode, string $message): RuntimeException
|
|
|
|
|
{
|
|
|
|
|
$verb = is_string($command['type'] ?? null) ? $command['type'] : 'unknown';
|
2026-06-20 07:23:16 +00:00
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
if ($statusCode === 501 || preg_match('/primitive .+ is not supported by host/i', $message) === 1) {
|
|
|
|
|
return new UnsupportedCooldVerb($verb, $message);
|
2026-06-20 07:23:16 +00:00
|
|
|
}
|
|
|
|
|
|
2026-07-06 15:40:37 +00:00
|
|
|
return new RuntimeException($message);
|
2026-06-20 07:23:16 +00:00
|
|
|
}
|
2026-06-20 13:08:45 +00:00
|
|
|
|
2026-06-20 21:10:48 +00:00
|
|
|
private function statusCode(string $response): int
|
|
|
|
|
{
|
|
|
|
|
if ($response === '') {
|
|
|
|
|
throw new RuntimeException('Flux did not return a response before the timeout. Check that coold is connected to Flux and try again.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (preg_match('/^HTTP\/\d(?:\.\d)?\s+(\d{3})/', $response, $matches) !== 1) {
|
|
|
|
|
throw new RuntimeException('Could not talk to Flux. Check that Flux is running in the Coolify container.');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (int) $matches[1];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function responseBody(string $response): string
|
|
|
|
|
{
|
|
|
|
|
$position = strpos($response, "\r\n\r\n");
|
|
|
|
|
|
|
|
|
|
return $position === false ? '' : substr($response, $position + 4);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
private function errorMessage(mixed $payload, string $responseBody): ?string
|
|
|
|
|
{
|
|
|
|
|
if (is_array($payload) && is_string($payload['message'] ?? null) && $payload['message'] !== '') {
|
|
|
|
|
return $payload['message'];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
$message = trim($responseBody);
|
|
|
|
|
|
|
|
|
|
return $message === '' ? null : Str::limit($message, 1000);
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-20 13:08:45 +00:00
|
|
|
/**
|
|
|
|
|
* @param array<string, mixed> $payload
|
|
|
|
|
*/
|
|
|
|
|
private function output(array $payload, string $fallback): string
|
|
|
|
|
{
|
|
|
|
|
$data = $payload['data'] ?? [];
|
|
|
|
|
$output = is_array($data) && is_string($data['output'] ?? null) ? $data['output'] : '';
|
|
|
|
|
|
|
|
|
|
return $output !== '' ? $output : $fallback;
|
|
|
|
|
}
|
2026-06-20 07:23:16 +00:00
|
|
|
}
|