mirror of
https://github.com/tiennm99/coolify.git
synced 2026-10-15 00:17:16 +00:00
feat(v5): sync server status and ingress access
Add Flux agent token issuing, open and revoke Caddy ingress firewall rules, broadcast server-scoped application status updates, and update the dashboard to show unreachable servers as unknown.
This commit is contained in:
1 parent
75cbcad240
commit
e3e91b7741
14 files changed
+557
-47
No files matched your search
@@ -12,6 +12,8 @@ class StartCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80';
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
@@ -28,6 +30,14 @@ class StartCaddyIngress
|
||||
|
||||
$configuration = GenerateCaddyIngressConfiguration::run($this->applications($server));
|
||||
$output = $this->fluxClient->applyIngress($hostId, 'caddy', $configuration['caddyfile'], $this->ingressApps($configuration['apps']));
|
||||
$this->fluxClient->applyFirewallRule($hostId, [
|
||||
'id' => self::FIREWALL_RULE_ID,
|
||||
'namespace' => 'default',
|
||||
'src' => '0.0.0.0/0',
|
||||
'dst' => 'coolify-v5-caddy',
|
||||
'proto' => 'tcp',
|
||||
'port' => 80,
|
||||
]);
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update([
|
||||
|
||||
@@ -10,6 +10,8 @@ class StopCaddyIngress
|
||||
{
|
||||
use AsAction;
|
||||
|
||||
private const FIREWALL_RULE_ID = 'v5-caddy-ingress:80';
|
||||
|
||||
public function __construct(private readonly FluxClient $fluxClient) {}
|
||||
|
||||
public function handle(Server $server): string
|
||||
@@ -21,6 +23,7 @@ class StopCaddyIngress
|
||||
}
|
||||
|
||||
$output = $this->fluxClient->stopIngress($hostId, 'caddy');
|
||||
$this->fluxClient->revokeFirewallRule($hostId, self::FIREWALL_RULE_ID);
|
||||
|
||||
if ($server->exists) {
|
||||
$server->update(['ingress_status' => 'exited']);
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace App\Console\Commands;
|
||||
|
||||
use Firebase\JWT\JWT;
|
||||
use App\Services\Flux\AgentTokenIssuer;
|
||||
use Illuminate\Console\Command;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Str;
|
||||
@@ -28,7 +28,7 @@ class FluxDev extends Command
|
||||
];
|
||||
}
|
||||
|
||||
public function handle(): int
|
||||
public function handle(AgentTokenIssuer $agentTokenIssuer): int
|
||||
{
|
||||
if (! app()->environment(['local', 'development', 'testing']) && ! $this->option('force')) {
|
||||
$this->error('This command is intended for development only. Use --force to override.');
|
||||
@@ -36,17 +36,8 @@ class FluxDev extends Command
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$privateKeyPath = config('flux.jwt_private_key_path');
|
||||
|
||||
if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) {
|
||||
$this->error("Flux JWT private key not found at {$privateKeyPath}.");
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$hostId = (string) $this->argument('host_id');
|
||||
$ttl = max(60, (int) $this->option('ttl'));
|
||||
$now = time();
|
||||
$caps = collect(explode(',', (string) $this->option('caps')))
|
||||
->map(fn (string $cap) => trim($cap))
|
||||
->filter()
|
||||
@@ -58,13 +49,13 @@ class FluxDev extends Command
|
||||
$caps = $this->defaultCapabilities();
|
||||
}
|
||||
|
||||
$token = JWT::encode([
|
||||
'sub' => $hostId,
|
||||
'aud' => 'coold',
|
||||
'caps' => $caps,
|
||||
'iat' => $now,
|
||||
'exp' => $now + $ttl,
|
||||
], File::get($privateKeyPath), 'ES256');
|
||||
try {
|
||||
$token = $agentTokenIssuer->issue($hostId, $caps, $ttl);
|
||||
} catch (\RuntimeException $exception) {
|
||||
$this->error($exception->getMessage());
|
||||
|
||||
return self::FAILURE;
|
||||
}
|
||||
|
||||
$output = $this->option('output');
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow
|
||||
public int $teamId,
|
||||
public ?int $applicationId = null,
|
||||
public ?int $caddyIngressServerId = null,
|
||||
public ?int $serverId = null,
|
||||
) {}
|
||||
|
||||
public function broadcastOn(): array
|
||||
@@ -33,19 +34,29 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array{application: array<string, mixed>|null, caddyIngress: array<string, mixed>|null}
|
||||
* @return array{application: array<string, mixed>|null, applications: array<int, array<string, mixed>>, caddyIngress: array<string, mixed>|null}
|
||||
*/
|
||||
public function broadcastWith(): array
|
||||
{
|
||||
$application = $this->applicationId !== null
|
||||
? V5Application::query()->with('server')->find($this->applicationId)
|
||||
? V5Application::query()->with(['server', 'domains'])->find($this->applicationId)
|
||||
: null;
|
||||
$applications = $this->serverId !== null
|
||||
? V5Application::query()
|
||||
->where('server_id', $this->serverId)
|
||||
->with(['server', 'domains'])
|
||||
->get()
|
||||
: collect();
|
||||
$caddyIngress = $this->caddyIngressServerId !== null
|
||||
? V5Server::query()->find($this->caddyIngressServerId)
|
||||
: null;
|
||||
|
||||
return [
|
||||
'application' => $application instanceof V5Application ? $this->serializeApplication($application) : null,
|
||||
'applications' => $applications
|
||||
->map(fn (V5Application $application) => $this->serializeApplication($application))
|
||||
->values()
|
||||
->all(),
|
||||
'caddyIngress' => $caddyIngress instanceof V5Server && $caddyIngress->isIngress()
|
||||
? $this->serializeCaddyIngress($caddyIngress)
|
||||
: null,
|
||||
@@ -57,6 +68,9 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow
|
||||
*/
|
||||
private function serializeApplication(V5Application $application): array
|
||||
{
|
||||
$server = $application->server;
|
||||
$isServerReachable = ! $server instanceof V5Server || $this->isServerReachable($server);
|
||||
|
||||
return [
|
||||
'id' => (string) $application->id,
|
||||
'name' => $application->name,
|
||||
@@ -64,26 +78,50 @@ class V5CanvasResourceUpdated implements ShouldBroadcastNow
|
||||
'containerName' => $application->container_name,
|
||||
'status' => $application->status,
|
||||
'statusMessage' => $application->status_message,
|
||||
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
|
||||
'effectiveStatusMessage' => $isServerReachable
|
||||
? $application->status_message
|
||||
: $this->serverStatusMessage($server),
|
||||
'runtimeContainerId' => $application->runtime_container_id,
|
||||
'serverName' => $application->server?->name,
|
||||
'serverName' => $server?->name,
|
||||
'serverStatus' => $server?->status,
|
||||
'serverStatusMessage' => $server instanceof V5Server ? $this->serverStatusMessage($server) : null,
|
||||
'isServerReachable' => $isServerReachable,
|
||||
'serverIngressEnabled' => (bool) $server?->isIngress(),
|
||||
'meshNamespace' => $application->mesh_namespace,
|
||||
'ingressEnabled' => $application->ingress_enabled,
|
||||
'internalPort' => $application->internal_port,
|
||||
'domains' => $application->domains->pluck('domain')->values()->all(),
|
||||
'meshFqdn' => $application->container_name.'.'.($application->mesh_namespace ?: 'default').'.coolify.internal',
|
||||
'canvasX' => $application->canvas_x,
|
||||
'canvasY' => $application->canvas_y,
|
||||
];
|
||||
}
|
||||
|
||||
private function isServerReachable(V5Server $server): bool
|
||||
{
|
||||
return $server->status !== 'unreachable';
|
||||
}
|
||||
|
||||
private function serverStatusMessage(?V5Server $server): ?string
|
||||
{
|
||||
return $server?->last_status_output ?: null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array<string, mixed>
|
||||
*/
|
||||
private function serializeCaddyIngress(V5Server $server): array
|
||||
{
|
||||
$isServerReachable = $this->isServerReachable($server);
|
||||
|
||||
return [
|
||||
'id' => (string) $server->id,
|
||||
'name' => $server->name,
|
||||
'host' => $server->host,
|
||||
'type' => $server->ingressType(),
|
||||
'status' => $server->ingressStatus(),
|
||||
'status' => $isServerReachable ? $server->ingressStatus() : 'unreachable',
|
||||
'statusMessage' => $isServerReachable ? null : $this->serverStatusMessage($server),
|
||||
'canvasX' => $server->canvas_x ?? -352,
|
||||
'canvasY' => $server->canvas_y ?? 0,
|
||||
];
|
||||
|
||||
@@ -1592,7 +1592,7 @@ class DashboardController extends Controller
|
||||
'containerName' => $application->container_name,
|
||||
'status' => $application->status,
|
||||
'statusMessage' => $application->status_message,
|
||||
'effectiveStatus' => $isServerReachable ? $application->status : 'unreachable',
|
||||
'effectiveStatus' => $isServerReachable ? $application->status : 'unknown',
|
||||
'effectiveStatusMessage' => $isServerReachable
|
||||
? $application->status_message
|
||||
: $this->serverStatusMessage($server),
|
||||
|
||||
@@ -63,6 +63,17 @@ class Server extends V5Model
|
||||
V5ClusterUpdated::dispatch($server->team_id, $server->cluster_id);
|
||||
}
|
||||
|
||||
if ($server->wasChanged('status')) {
|
||||
V5CanvasResourceUpdated::dispatch(
|
||||
$server->team_id,
|
||||
null,
|
||||
$server->isIngress() ? $server->id : null,
|
||||
$server->id,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if ($server->isIngress()) {
|
||||
V5CanvasResourceUpdated::dispatch($server->team_id, null, $server->id);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Flux;
|
||||
|
||||
use App\Models\V5\Server as V5Server;
|
||||
use Firebase\JWT\JWT;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use RuntimeException;
|
||||
|
||||
class AgentTokenIssuer
|
||||
{
|
||||
public const DEFAULT_PROFILE = 'host-agent:default';
|
||||
|
||||
/**
|
||||
* @param array<int, string> $capabilities
|
||||
* @param array<string, mixed> $extraClaims
|
||||
*/
|
||||
public function issue(string $hostId, array $capabilities = [self::DEFAULT_PROFILE], int $ttl = 86400, array $extraClaims = []): string
|
||||
{
|
||||
if ($hostId === '') {
|
||||
throw new RuntimeException('Flux host id is required.');
|
||||
}
|
||||
|
||||
$privateKeyPath = config('flux.jwt_private_key_path');
|
||||
|
||||
if (! is_string($privateKeyPath) || $privateKeyPath === '' || ! File::isReadable($privateKeyPath)) {
|
||||
throw new RuntimeException("Flux JWT private key not found at {$privateKeyPath}.");
|
||||
}
|
||||
|
||||
$now = time();
|
||||
|
||||
return JWT::encode(array_merge($extraClaims, [
|
||||
'sub' => $hostId,
|
||||
'aud' => 'coold',
|
||||
'caps' => $this->normalizeCapabilities($capabilities),
|
||||
'iat' => $now,
|
||||
'exp' => $now + max(60, $ttl),
|
||||
]), File::get($privateKeyPath), 'ES256');
|
||||
}
|
||||
|
||||
public function issueForServer(V5Server $server, int $ttl = 86400): string
|
||||
{
|
||||
$hostId = $server->wireguard_management_ip ?: $server->node_address;
|
||||
|
||||
if (! is_string($hostId) || $hostId === '') {
|
||||
throw new RuntimeException('Server is missing its 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,
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array<int, string> $capabilities
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeCapabilities(array $capabilities): array
|
||||
{
|
||||
$normalized = collect($capabilities)
|
||||
->map(fn (string $capability) => trim($capability))
|
||||
->filter()
|
||||
->unique()
|
||||
->values()
|
||||
->all();
|
||||
|
||||
if ($normalized === []) {
|
||||
return [self::DEFAULT_PROFILE];
|
||||
}
|
||||
|
||||
return $normalized;
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user