feat(v5): add server reconciliation and canvas APIs

Split V5 dashboard behavior into domain controllers and policies,
add agent token rotation/revocation, status reconciliation jobs,
ingress firewall syncing, and canvas connection APIs.

Add migrations for V5 status tracking, server capabilities, resource
connection aliases, and revoked agent tokens.
This commit is contained in:
Andras Bacsai committed 2026-07-06 17:40:37 +02:00
1 parent df854feee8
commit 6ae45684f9
147 files changed
+22444 -10407

No files matched your search

+190 -13
View File
@@ -2,20 +2,41 @@
namespace App\Services\Flux;
use App\Models\V5\RevokedAgentToken;
use App\Models\V5\Server as V5Server;
use Firebase\JWT\JWT;
use Illuminate\Support\Facades\File;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
use RuntimeException;
/**
* Mints the per-host ES256 JWT that authorizes a coold host agent against flux.
*
* Capability scoping: by default the token carries the EXPLICIT list of
* primitive capability strings coold advertises (config('flux.host_capabilities'),
* mirroring coold/coold/src/grpc/client.rs:204-231) rather than the
* `host-agent:default` wildcard profile. flux intersects the jwt `caps` with
* coold's advertised set (flux/src/main.rs:128-141), so the effective power is
* unchanged, but the token no longer depends on flux's
* `capability_profile_authorizes_all` wildcard bypass (main.rs:124-126).
*
* @see config/flux.php for the capability list, escape hatch, TTL and kid config.
*/
class AgentTokenIssuer
{
public const DEFAULT_PROFILE = 'host-agent:default';
private const TTL_FLOOR_SECONDS = 60;
/**
* @param array<int, string> $capabilities
* @param array<string, mixed> $extraClaims
* Mint a host JWT.
*
* @param array<int, string>|null $capabilities Explicit caps; null resolves the configured default set (or escape-hatch profile).
* @param int|null $ttl Lifetime in seconds; null resolves config('flux.host_token_ttl'). Clamped to a 60s floor.
* @param array<string, mixed> $extraClaims Extra claims merged in (a `jti` here is honored, otherwise one is generated).
*/
public function issue(string $hostId, array $capabilities = [self::DEFAULT_PROFILE], int $ttl = 86400, array $extraClaims = []): string
public function issue(string $hostId, ?array $capabilities = null, ?int $ttl = null, array $extraClaims = []): string
{
if ($hostId === '') {
throw new RuntimeException('Flux host id is required.');
@@ -27,30 +48,186 @@ class AgentTokenIssuer
throw new RuntimeException("Flux JWT private key not found at {$privateKeyPath}.");
}
$this->assertPrivateKeyPermissions($privateKeyPath);
$capabilities ??= $this->defaultCapabilities();
$ttl ??= (int) config('flux.host_token_ttl', 3600);
$jti = $extraClaims['jti'] ?? (string) Str::uuid();
unset($extraClaims['jti']);
$now = time();
$keyId = (string) config('flux.jwt_kid', 'flux-default');
return JWT::encode(array_merge($extraClaims, [
'sub' => $hostId,
'aud' => 'coold',
'caps' => $this->normalizeCapabilities($capabilities),
'jti' => $jti,
'iat' => $now,
'exp' => $now + max(60, $ttl),
]), File::get($privateKeyPath), 'ES256');
'exp' => $now + max(self::TTL_FLOOR_SECONDS, $ttl),
]), File::get($privateKeyPath), 'ES256', $keyId !== '' ? $keyId : null);
}
public function issueForServer(V5Server $server, int $ttl = 86400): string
public function issueForServer(V5Server $server, ?int $ttl = null): string
{
$hostId = $server->wireguard_management_ip ?: $server->node_address;
$hostId = $server->fluxHostId();
if (! is_string($hostId) || $hostId === '') {
throw new RuntimeException('Server is missing its Flux host id.');
if ($hostId === '') {
throw new RuntimeException('Server is missing a valid Flux host id.');
}
return $this->issue($hostId, [self::DEFAULT_PROFILE], $ttl, [
'team_id' => $server->team_id,
'cluster_id' => $server->cluster_id,
'server_id' => $server->id,
$jti = (string) Str::uuid();
$ttl ??= (int) config('flux.host_token_ttl', 3600);
// team_id/cluster_id/server_id are minted as STRINGS: flux deserializes
// the `team_id` claim as a string (coold/flux/src/auth.rs Claims), and
// rejects the whole token with a JSON type error if it arrives as a JSON
// integer. Keep the sibling ids string-typed for consistency.
$token = $this->issue($hostId, $this->defaultCapabilities(), $ttl, [
'jti' => $jti,
'team_id' => (string) $server->team_id,
'cluster_id' => (string) $server->cluster_id,
'server_id' => $hostId,
'wireguard_management_ip' => (string) $server->wireguard_management_ip,
]);
// Persist the freshly issued jti (so a later destroy/re-home knows which
// token to revoke) and its expiry (so the scheduled rotation loop knows
// when to re-mint). Use a targeted update keyed by id so this neither
// inserts an unsaved model nor flushes unrelated dirty attributes, and
// does not depend on the Server model's $fillable.
if ($server->exists) {
$expiresAt = now()->addSeconds(max(self::TTL_FLOOR_SECONDS, $ttl));
V5Server::query()->whereKey($server->getKey())->update([
'agent_token_jti' => $jti,
'agent_token_expires_at' => $expiresAt,
]);
$server->setAttribute('agent_token_jti', $jti);
$server->setAttribute('agent_token_expires_at', $expiresAt);
$server->syncOriginalAttribute('agent_token_jti');
$server->syncOriginalAttribute('agent_token_expires_at');
}
return $token;
}
/**
* Record the server's currently-issued host token jti as revoked AND push
* the revocation to flux so it rejects the jti at verify immediately.
*
* flux now consults a revocation denylist (flux/src/auth.rs `is_revoked`,
* fed by `POST /v1/tokens/revoke` on the flux UDS), and Laravel pushes to it
* here. The local `RevokedAgentToken` record remains the source of truth
* Laravel owns; the flux push is best-effort — if flux is unreachable the
* revocation is logged and the local record still stands, with the short TTL
* and hourly rotation bounding the exposure until flux is reachable again.
*/
public function revoke(V5Server $server): void
{
$jti = $server->agent_token_jti;
if (! is_string($jti) || $jti === '') {
return;
}
$expiresAt = $server->agent_token_expires_at;
$expiresAtUnix = $expiresAt instanceof \DateTimeInterface ? $expiresAt->getTimestamp() : null;
RevokedAgentToken::query()->updateOrCreate(
['jti' => $jti],
[
'server_id' => $server->id,
'revoked_at' => now(),
'expires_at' => $expiresAt,
]
);
// Best-effort: a destroy/teardown must never fail because flux is down.
try {
app(FluxClient::class)->revokeToken($jti, $expiresAtUnix);
} catch (\Throwable $exception) {
Log::warning('Failed to push agent token revocation to Flux.', [
'server_id' => $server->id,
'jti' => $jti,
'error' => $exception->getMessage(),
]);
}
if ($server->exists) {
V5Server::query()->whereKey($server->getKey())->update(['agent_token_jti' => null]);
$server->setAttribute('agent_token_jti', null);
$server->syncOriginalAttribute('agent_token_jti');
}
}
/**
* Revoke the server's currently-issued host token. Alias of {@see revoke()}
* kept as the name the team-teardown job resolves via `method_exists`.
*/
public function revokeForServer(V5Server $server): void
{
$this->revoke($server);
}
public function isRevoked(string $jti): bool
{
if ($jti === '') {
return false;
}
return RevokedAgentToken::query()->where('jti', $jti)->exists();
}
/**
* The default capability set for production host tokens: the explicit
* advertised primitive list, unless the emergency escape hatch profile is
* configured (then that single profile is minted instead).
*
* @return array<int, string>
*/
private function defaultCapabilities(): array
{
$profile = config('flux.host_capability_profile');
if (is_string($profile) && trim($profile) !== '') {
return [trim($profile)];
}
$configured = config('flux.host_capabilities');
if (is_array($configured) && $configured !== []) {
return array_values($configured);
}
return [self::DEFAULT_PROFILE];
}
/**
* Warn (but do not hard-fail — that could break existing installs) when the
* private key file is readable by group/other or is not owner-readable. The
* key should be generated 0600, e.g.:
* openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 \
* -out storage/app/flux/jwt.priv && chmod 600 storage/app/flux/jwt.priv
*/
private function assertPrivateKeyPermissions(string $path): void
{
$perms = @fileperms($path);
if ($perms === false) {
return;
}
$mode = $perms & 0777;
if (($mode & 0077) !== 0 || ($mode & 0400) === 0) {
Log::warning('Flux JWT private key has insecure permissions.', [
'path' => $path,
'mode' => sprintf('%04o', $mode),
'expected' => '0600',
]);
}
}
/**
+151 -22
View File
@@ -2,6 +2,7 @@
namespace App\Services\Flux;
use App\Exceptions\V5\UnsupportedCooldVerb;
use Illuminate\Support\Str;
use RuntimeException;
@@ -60,6 +61,28 @@ class FluxClient
return $this->output($payload, 'Container started.');
}
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.');
}
/**
* @return array<string, mixed>
*/
@@ -151,6 +174,19 @@ class FluxClient
return $this->output($payload, 'No coold logs returned.');
}
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.');
}
public function corrosionTables(string $hostId, int $limit = 200): string
{
$payload = $this->dispatch($hostId, [
@@ -161,11 +197,107 @@ class FluxClient
return $this->output($payload, '{"limit":200,"tables":[]}');
}
/**
* 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}."
);
}
}
/**
* @param array<string, mixed> $command
* @return array<string, mixed>
*/
private function dispatch(string $hostId, array $command): array
{
$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
{
$socketPath = config('flux.unix_socket_path');
@@ -177,11 +309,6 @@ class FluxClient
throw new RuntimeException('Flux socket was not found.');
}
$body = json_encode([
'host_id' => $hostId,
'request_id' => (string) Str::uuid(),
'command' => $command,
], JSON_THROW_ON_ERROR);
$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);
@@ -193,7 +320,7 @@ class FluxClient
stream_set_timeout($stream, (int) ceil($dispatchTimeout));
fwrite($stream, implode("\r\n", [
'POST /v1/coold/dispatch HTTP/1.1',
"POST {$path} HTTP/1.1",
'Host: flux',
'Accept: application/json',
'Content-Type: application/json',
@@ -206,25 +333,27 @@ class FluxClient
$response = stream_get_contents($stream) ?: '';
fclose($stream);
$statusCode = $this->statusCode($response);
$responseBody = $this->responseBody($response);
$payload = $responseBody === '' ? null : json_decode($responseBody, true);
return $response;
}
if ($statusCode < 200 || $statusCode >= 300) {
throw new RuntimeException($this->errorMessage($payload, $responseBody) ?? "Flux dispatch returned HTTP {$statusCode}.");
/**
* 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';
if ($statusCode === 501 || preg_match('/primitive .+ is not supported by host/i', $message) === 1) {
return new UnsupportedCooldVerb($verb, $message);
}
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 new RuntimeException($message);
}
return $payload;
return new RuntimeException($message);
}
private function statusCode(string $response): int