Fix api-v2 CORS at the edge without changing rollout flows (#330)
Add API-only Traefik CORS middleware labels while preserving configured origins and the existing rollout/load-balancer behavior.
This commit is contained in:
@@ -5,6 +5,8 @@ namespace classes;
|
|||||||
use RuntimeException;
|
use RuntimeException;
|
||||||
use Throwable;
|
use Throwable;
|
||||||
|
|
||||||
|
require_once __DIR__ . '/cors_policy.php';
|
||||||
|
|
||||||
class coolify_manager
|
class coolify_manager
|
||||||
{
|
{
|
||||||
private const KINDS = ['database', 'redis', 'minio'];
|
private const KINDS = ['database', 'redis', 'minio'];
|
||||||
@@ -1070,7 +1072,9 @@ class coolify_manager
|
|||||||
$targetPublicUrl,
|
$targetPublicUrl,
|
||||||
$resourceUuid,
|
$resourceUuid,
|
||||||
self::resourceFirstExposedPort($resource, $target),
|
self::resourceFirstExposedPort($resource, $target),
|
||||||
$resource['custom_labels'] ?? null
|
$resource['custom_labels'] ?? null,
|
||||||
|
$app,
|
||||||
|
self::gatewayRouteTargetCorsConfig($target)
|
||||||
);
|
);
|
||||||
$update = $resourceType === 'service'
|
$update = $resourceType === 'service'
|
||||||
? $client->updateService($resourceUuid, $updatePayload)
|
? $client->updateService($resourceUuid, $updatePayload)
|
||||||
@@ -2420,7 +2424,9 @@ class coolify_manager
|
|||||||
string $publicUrl,
|
string $publicUrl,
|
||||||
string $resourceUuid = '',
|
string $resourceUuid = '',
|
||||||
?int $port = null,
|
?int $port = null,
|
||||||
mixed $existingLabels = null
|
mixed $existingLabels = null,
|
||||||
|
string $app = '',
|
||||||
|
string $corsConfig = ''
|
||||||
): array
|
): array
|
||||||
{
|
{
|
||||||
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
|
$decodedLabels = self::decodeCoolifyLabels($existingLabels);
|
||||||
@@ -2435,7 +2441,9 @@ class coolify_manager
|
|||||||
$publicUrl,
|
$publicUrl,
|
||||||
$resourceUuid,
|
$resourceUuid,
|
||||||
$routePort,
|
$routePort,
|
||||||
self::gatewayRouteDefaultCertResolver($publicUrl)
|
self::gatewayRouteDefaultCertResolver($publicUrl),
|
||||||
|
$app,
|
||||||
|
$corsConfig
|
||||||
);
|
);
|
||||||
if ($labels !== []) {
|
if ($labels !== []) {
|
||||||
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
||||||
@@ -2460,6 +2468,49 @@ class coolify_manager
|
|||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static function gatewayRouteTargetCorsConfig(array $target): string
|
||||||
|
{
|
||||||
|
$context = self::jsonDecode($target['deploy_context_json'] ?? null);
|
||||||
|
$configured = null;
|
||||||
|
|
||||||
|
foreach (['coolify_env', 'runtime_env', 'environment_variables'] as $key) {
|
||||||
|
$env = $context[$key] ?? null;
|
||||||
|
if (is_array($env) && array_key_exists('CORS', $env) && is_scalar($env['CORS'])) {
|
||||||
|
$configured = (string)$env['CORS'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (['coolify_env_file', 'env'] as $key) {
|
||||||
|
$raw = $context[$key] ?? null;
|
||||||
|
if (!is_string($raw)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
foreach (preg_split('/\r\n|\r|\n/', $raw) ?: [] as $line) {
|
||||||
|
$line = trim((string)$line);
|
||||||
|
if ($line === '' || str_starts_with($line, '#') || !str_contains($line, '=')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
[$envKey, $value] = explode('=', $line, 2);
|
||||||
|
if (trim($envKey) === 'CORS') {
|
||||||
|
$configured = $value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($configured === null) {
|
||||||
|
$runtimeValue = getenv('CORS');
|
||||||
|
if ($runtimeValue !== false) {
|
||||||
|
$configured = $runtimeValue;
|
||||||
|
} elseif (array_key_exists('CORS', $_ENV ?? [])) {
|
||||||
|
$configured = (string)$_ENV['CORS'];
|
||||||
|
} elseif (array_key_exists('CORS', $_SERVER ?? [])) {
|
||||||
|
$configured = (string)$_SERVER['CORS'];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return cors_policy::withRequiredOrigins((string)($configured ?? ''));
|
||||||
|
}
|
||||||
|
|
||||||
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
|
private static function coolifyProxyUrl(string $publicUrl, ?int $port): string
|
||||||
{
|
{
|
||||||
if ($port === null || $port <= 0) {
|
if ($port === null || $port <= 0) {
|
||||||
@@ -2483,7 +2534,9 @@ class coolify_manager
|
|||||||
string $publicUrl,
|
string $publicUrl,
|
||||||
string $resourceUuid,
|
string $resourceUuid,
|
||||||
?int $port = null,
|
?int $port = null,
|
||||||
?string $certResolver = null
|
?string $certResolver = null,
|
||||||
|
string $app = '',
|
||||||
|
string $corsConfig = ''
|
||||||
): array
|
): array
|
||||||
{
|
{
|
||||||
$resourceUuid = self::gatewayRouteLabelId($resourceUuid);
|
$resourceUuid = self::gatewayRouteLabelId($resourceUuid);
|
||||||
@@ -2508,6 +2561,7 @@ class coolify_manager
|
|||||||
$certResolver = trim((string)($certResolver ?? ''));
|
$certResolver = trim((string)($certResolver ?? ''));
|
||||||
$httpLabel = 'http-0-' . $resourceUuid;
|
$httpLabel = 'http-0-' . $resourceUuid;
|
||||||
$httpsLabel = 'https-0-' . $resourceUuid;
|
$httpsLabel = 'https-0-' . $resourceUuid;
|
||||||
|
$isApi = strtolower(trim($app)) === 'api';
|
||||||
$labels = [
|
$labels = [
|
||||||
'traefik.enable=true',
|
'traefik.enable=true',
|
||||||
'traefik.http.middlewares.gzip.compress=true',
|
'traefik.http.middlewares.gzip.compress=true',
|
||||||
@@ -2521,12 +2575,18 @@ class coolify_manager
|
|||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
||||||
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
||||||
}
|
}
|
||||||
|
$httpsMiddlewares = [];
|
||||||
|
if ($isApi) {
|
||||||
|
$corsMiddleware = "{$httpsLabel}-cors";
|
||||||
|
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
|
||||||
|
$httpsMiddlewares[] = $corsMiddleware;
|
||||||
|
}
|
||||||
if ($path !== '/') {
|
if ($path !== '/') {
|
||||||
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
|
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
|
||||||
} else {
|
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
|
|
||||||
}
|
}
|
||||||
|
$httpsMiddlewares[] = 'gzip';
|
||||||
|
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
|
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
|
||||||
if ($certResolver !== '') {
|
if ($certResolver !== '') {
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
|
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
|
||||||
|
|||||||
@@ -88,6 +88,39 @@ class cors_policy
|
|||||||
return self::REQUIRED_ALLOWED_ORIGINS;
|
return self::REQUIRED_ALLOWED_ORIGINS;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array<int,string>
|
||||||
|
*/
|
||||||
|
public static function traefikHeadersMiddlewareLabels(string $middlewareName, string $corsConfig = ''): array
|
||||||
|
{
|
||||||
|
$middlewareName = trim($middlewareName);
|
||||||
|
if ($middlewareName === '' || preg_match('/^[a-zA-Z0-9-]+$/', $middlewareName) !== 1) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$allowedHeaders = array_values(array_filter(
|
||||||
|
array_map('trim', explode(',', self::ALLOWED_HEADERS)),
|
||||||
|
static fn(string $header): bool => $header !== '' && $header !== '*'
|
||||||
|
));
|
||||||
|
$allowedMethods = array_values(array_filter(array_map('trim', explode(',', self::ALLOWED_METHODS))));
|
||||||
|
$exposedHeaders = array_values(array_filter(array_map('trim', explode(',', self::EXPOSED_HEADERS))));
|
||||||
|
$prefix = "traefik.http.middlewares.{$middlewareName}.headers";
|
||||||
|
$allowedOrigins = self::allowedOrigins($corsConfig);
|
||||||
|
$originLabel = $allowedOrigins === ['*']
|
||||||
|
? "{$prefix}.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$"
|
||||||
|
: "{$prefix}.accesscontrolalloworiginlist=" . implode(',', $allowedOrigins);
|
||||||
|
|
||||||
|
return [
|
||||||
|
"{$prefix}.accesscontrolallowcredentials=true",
|
||||||
|
"{$prefix}.accesscontrolallowheaders=" . implode(',', $allowedHeaders),
|
||||||
|
"{$prefix}.accesscontrolallowmethods=" . implode(',', $allowedMethods),
|
||||||
|
$originLabel,
|
||||||
|
"{$prefix}.accesscontrolexposeheaders=" . implode(',', $exposedHeaders),
|
||||||
|
"{$prefix}.accesscontrolmaxage=" . self::MAX_AGE_SECONDS,
|
||||||
|
"{$prefix}.addvaryheader=true",
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array<int,string>
|
* @return array<int,string>
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -8408,7 +8408,9 @@ class release_manager
|
|||||||
$publicUrl,
|
$publicUrl,
|
||||||
$resourceUuid,
|
$resourceUuid,
|
||||||
$routePort,
|
$routePort,
|
||||||
self::gatewayRouteDefaultCertResolver($publicUrl)
|
self::gatewayRouteDefaultCertResolver($publicUrl),
|
||||||
|
(string)($target['app'] ?? ''),
|
||||||
|
(string)($this->releaseCoolifyRuntimeEnv($target, $context)['CORS'] ?? '')
|
||||||
);
|
);
|
||||||
if ($labels !== []) {
|
if ($labels !== []) {
|
||||||
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
$payload['custom_labels'] = base64_encode(implode("\n", self::mergeCoolifyLabels(
|
||||||
@@ -8461,7 +8463,9 @@ class release_manager
|
|||||||
string $publicUrl,
|
string $publicUrl,
|
||||||
string $resourceUuid,
|
string $resourceUuid,
|
||||||
?int $port = null,
|
?int $port = null,
|
||||||
?string $certResolver = null
|
?string $certResolver = null,
|
||||||
|
string $app = '',
|
||||||
|
string $corsConfig = ''
|
||||||
): array
|
): array
|
||||||
{
|
{
|
||||||
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
|
$resourceUuid = self::coolifyRouteLabelId($resourceUuid);
|
||||||
@@ -8487,6 +8491,7 @@ class release_manager
|
|||||||
$httpLabel = 'http-0-' . $resourceUuid;
|
$httpLabel = 'http-0-' . $resourceUuid;
|
||||||
$httpsLabel = 'https-0-' . $resourceUuid;
|
$httpsLabel = 'https-0-' . $resourceUuid;
|
||||||
$priority = (string)(1000 + strlen($path));
|
$priority = (string)(1000 + strlen($path));
|
||||||
|
$isApi = strtolower(trim($app)) === 'api';
|
||||||
$labels = [
|
$labels = [
|
||||||
'traefik.enable=true',
|
'traefik.enable=true',
|
||||||
'traefik.http.middlewares.gzip.compress=true',
|
'traefik.http.middlewares.gzip.compress=true',
|
||||||
@@ -8501,12 +8506,18 @@ class release_manager
|
|||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
$labels[] = "traefik.http.routers.{$httpsLabel}.service={$httpsLabel}";
|
||||||
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
$labels[] = "traefik.http.services.{$httpsLabel}.loadbalancer.server.port={$routePort}";
|
||||||
}
|
}
|
||||||
|
$httpsMiddlewares = [];
|
||||||
|
if ($isApi) {
|
||||||
|
$corsMiddleware = "{$httpsLabel}-cors";
|
||||||
|
$labels = array_merge($labels, cors_policy::traefikHeadersMiddlewareLabels($corsMiddleware, $corsConfig));
|
||||||
|
$httpsMiddlewares[] = $corsMiddleware;
|
||||||
|
}
|
||||||
if ($path !== '/') {
|
if ($path !== '/') {
|
||||||
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
$labels[] = "traefik.http.middlewares.{$httpsLabel}-stripprefix.stripprefix.prefixes={$path}";
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares={$httpsLabel}-stripprefix,gzip";
|
$httpsMiddlewares[] = "{$httpsLabel}-stripprefix";
|
||||||
} else {
|
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=gzip";
|
|
||||||
}
|
}
|
||||||
|
$httpsMiddlewares[] = 'gzip';
|
||||||
|
$labels[] = "traefik.http.routers.{$httpsLabel}.middlewares=" . implode(',', $httpsMiddlewares);
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
|
$labels[] = "traefik.http.routers.{$httpsLabel}.tls=true";
|
||||||
if ($certResolver !== '') {
|
if ($certResolver !== '') {
|
||||||
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
|
$labels[] = "traefik.http.routers.{$httpsLabel}.tls.certresolver={$certResolver}";
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ app_require('classes/coolify_manager.php');
|
|||||||
|
|
||||||
use classes\coolify_api_client;
|
use classes\coolify_api_client;
|
||||||
use classes\coolify_manager;
|
use classes\coolify_manager;
|
||||||
|
use classes\cors_policy;
|
||||||
|
|
||||||
class CoolifyManagerHetznerTargetSetFake
|
class CoolifyManagerHetznerTargetSetFake
|
||||||
{
|
{
|
||||||
@@ -418,9 +419,10 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
|
|||||||
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
|
$payload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io', 'api-app-uuid', 8080, base64_encode(implode("\n", [
|
||||||
'custom.keep=true',
|
'custom.keep=true',
|
||||||
'traefik.http.routers.https-0-api-app-uuid.entryPoints=old',
|
'traefik.http.routers.https-0-api-app-uuid.entryPoints=old',
|
||||||
|
'traefik.http.routers.https-0-api-app-uuid.middlewares=legacy',
|
||||||
'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare',
|
'traefik.http.routers.https-0-api-app-uuid.tls.certresolver=dns-cloudflare',
|
||||||
'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090',
|
'traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=9090',
|
||||||
])));
|
])), 'api', 'https://partner.example.test/app');
|
||||||
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
|
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
|
||||||
|
|
||||||
expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080')
|
expect($payload['domains'])->toBe('https://api-v2.truckwash.io:8080')
|
||||||
@@ -429,6 +431,12 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
|
|||||||
->and($labels)->toContain('custom.keep=true')
|
->and($labels)->toContain('custom.keep=true')
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.entryPoints=https')
|
||||||
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip')
|
||||||
|
->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true')
|
||||||
|
->and($labels)->toContain(
|
||||||
|
'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist='
|
||||||
|
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
|
||||||
|
)
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
||||||
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
||||||
@@ -449,21 +457,49 @@ it('adds explicit Coolify application route labels for gateway API domains', fun
|
|||||||
'app' => 'frontend',
|
'app' => 'frontend',
|
||||||
]))->toBe('https://api-v2.truckwash.io/internal/frontend');
|
]))->toBe('https://api-v2.truckwash.io/internal/frontend');
|
||||||
|
|
||||||
$pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, '');
|
$pathPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/api', 'api-app-uuid', 8080, '', 'api', '*');
|
||||||
$pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true));
|
$pathLabels = explode("\n", base64_decode($pathPayload['custom_labels'], true));
|
||||||
|
|
||||||
expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api')
|
expect($pathPayload['domains'])->toBe('https://api-v2.truckwash.io:8080/internal/api')
|
||||||
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)')
|
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)')
|
||||||
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api')
|
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-stripprefix.stripprefix.prefixes=/internal/api')
|
||||||
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-stripprefix,gzip');
|
->and($pathLabels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlistregex=^(https?://[^/]+|capacitor://[^/]+)$')
|
||||||
|
->and($pathLabels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,https-0-api-app-uuid-stripprefix,gzip');
|
||||||
|
|
||||||
$frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, '');
|
$frontendPayload = $payloadMethod->invoke(null, 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', 80, '', 'frontend');
|
||||||
$frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true));
|
$frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true));
|
||||||
|
|
||||||
expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend')
|
expect($frontendPayload['domains'])->toBe('https://api-v2.truckwash.io:80/internal/frontend')
|
||||||
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)')
|
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/frontend`)')
|
||||||
->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend')
|
->and($frontendLabels)->toContain('traefik.http.middlewares.https-0-frontend-app-uuid-stripprefix.stripprefix.prefixes=/internal/frontend')
|
||||||
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip');
|
->and($frontendLabels)->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip');
|
||||||
|
expect(implode("\n", $frontendLabels))->not->toContain('-cors');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves gateway CORS from deployment context with the same precedence as runtime env', function (): void {
|
||||||
|
$method = new ReflectionMethod(coolify_manager::class, 'gatewayRouteTargetCorsConfig');
|
||||||
|
$config = $method->invoke(null, [
|
||||||
|
'deploy_context_json' => json_encode([
|
||||||
|
'coolify_env' => [
|
||||||
|
'CORS' => 'https://array.example.test/app',
|
||||||
|
],
|
||||||
|
'coolify_env_file' => "CORS=https://file.example.test/path\nOTHER=value",
|
||||||
|
'env' => "CORS=*\n",
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect($config)->toBe('*');
|
||||||
|
|
||||||
|
$requiredConfig = $method->invoke(null, [
|
||||||
|
'deploy_context_json' => json_encode([
|
||||||
|
'runtime_env' => [
|
||||||
|
'CORS' => 'https://partner.example.test/app',
|
||||||
|
],
|
||||||
|
]),
|
||||||
|
]);
|
||||||
|
expect(explode(',', $requiredConfig))
|
||||||
|
->toContain('https://partner.example.test')
|
||||||
|
->toContain('https://truckwash.io');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
|
it('isolates and restores Hetzner load balancer IP targets for gateway certificate bootstrap', function (): void {
|
||||||
|
|||||||
@@ -27,6 +27,51 @@ it('merges required release and existing frontend origins into configured CORS',
|
|||||||
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api');
|
expect($origins)->not->toContain('https://api-v2.truckwash.io/master/api');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('builds credential-safe Traefik CORS middleware labels from the canonical policy', function (): void {
|
||||||
|
$labels = cors_policy::traefikHeadersMiddlewareLabels(
|
||||||
|
'https-0-api-app-cors',
|
||||||
|
'https://partner.example.test/app'
|
||||||
|
);
|
||||||
|
$allowedHeadersLabel = current(array_filter(
|
||||||
|
$labels,
|
||||||
|
static fn(string $label): bool => str_contains($label, '.accesscontrolallowheaders=')
|
||||||
|
));
|
||||||
|
|
||||||
|
expect($labels)
|
||||||
|
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolallowcredentials=true')
|
||||||
|
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolallowmethods=GET,POST,PUT,PATCH,DELETE,OPTIONS')
|
||||||
|
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolexposeheaders=Server-Timing')
|
||||||
|
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolmaxage=86400')
|
||||||
|
->toContain('traefik.http.middlewares.https-0-api-app-cors.headers.addvaryheader=true');
|
||||||
|
expect($labels)
|
||||||
|
->toContain(
|
||||||
|
'traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolalloworiginlist='
|
||||||
|
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
|
||||||
|
);
|
||||||
|
expect($allowedHeadersLabel)
|
||||||
|
->toContain('Content-Type')
|
||||||
|
->toContain('Authorization')
|
||||||
|
->toContain('X-Customer-Number')
|
||||||
|
->toContain('X-Release-Trace')
|
||||||
|
->toContain('X-Release-Channel')
|
||||||
|
->toContain('X-Frontend-Version')
|
||||||
|
->toContain('Cache-Control')
|
||||||
|
->toContain('Pragma')
|
||||||
|
->not->toContain(',*');
|
||||||
|
expect(cors_policy::traefikHeadersMiddlewareLabels('invalid middleware'))->toBe([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses an origin-reflecting Traefik regex for credentialed wildcard CORS', function (): void {
|
||||||
|
$labels = cors_policy::traefikHeadersMiddlewareLabels('https-0-api-app-cors', '*');
|
||||||
|
|
||||||
|
expect($labels)
|
||||||
|
->toContain(
|
||||||
|
'traefik.http.middlewares.https-0-api-app-cors.headers.accesscontrolalloworiginlistregex='
|
||||||
|
. '^(https?://[^/]+|capacitor://[^/]+)$'
|
||||||
|
);
|
||||||
|
expect(implode("\n", $labels))->not->toContain('accesscontrolalloworiginlist=*');
|
||||||
|
});
|
||||||
|
|
||||||
it('requires a syntactically exact request origin', function (): void {
|
it('requires a syntactically exact request origin', function (): void {
|
||||||
expect(cors_policy::normalizeRequestOrigin('capacitor://localhost'))
|
expect(cors_policy::normalizeRequestOrigin('capacitor://localhost'))
|
||||||
->toBe('capacitor://localhost');
|
->toBe('capacitor://localhost');
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ app_require('classes/release_manager_schema_bootstrap.php');
|
|||||||
app_require('classes/coolify_api_client.php');
|
app_require('classes/coolify_api_client.php');
|
||||||
|
|
||||||
use classes\coolify_api_client;
|
use classes\coolify_api_client;
|
||||||
|
use classes\cors_policy;
|
||||||
use classes\release_manager;
|
use classes\release_manager;
|
||||||
|
|
||||||
class ReleaseManagerCoolifyEnvFake extends coolify_api_client
|
class ReleaseManagerCoolifyEnvFake extends coolify_api_client
|
||||||
@@ -584,6 +585,9 @@ it('builds explicit Coolify application route labels for release API targets', f
|
|||||||
'branch' => 'master',
|
'branch' => 'master',
|
||||||
], [
|
], [
|
||||||
'coolify_ports_exposes' => '8080',
|
'coolify_ports_exposes' => '8080',
|
||||||
|
'runtime_env' => [
|
||||||
|
'CORS' => 'https://partner.example.test/app',
|
||||||
|
],
|
||||||
], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true'));
|
], 'https://api-v2.truckwash.io', 'api-app-uuid', base64_encode('custom.keep=true'));
|
||||||
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
|
$labels = explode("\n", base64_decode($payload['custom_labels'], true));
|
||||||
|
|
||||||
@@ -593,9 +597,29 @@ it('builds explicit Coolify application route labels for release API targets', f
|
|||||||
->and($labels)->toContain('custom.keep=true')
|
->and($labels)->toContain('custom.keep=true')
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/`)')
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.priority=1001')
|
||||||
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.middlewares=https-0-api-app-uuid-cors,gzip')
|
||||||
|
->and($labels)->toContain('traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolallowcredentials=true')
|
||||||
|
->and($labels)->toContain(
|
||||||
|
'traefik.http.middlewares.https-0-api-app-uuid-cors.headers.accesscontrolalloworiginlist='
|
||||||
|
. implode(',', cors_policy::allowedOrigins('https://partner.example.test/app'))
|
||||||
|
)
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.certresolver=letsencrypt')
|
||||||
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
->and($labels)->toContain('traefik.http.routers.https-0-api-app-uuid.tls.domains[0].main=api-v2.truckwash.io')
|
||||||
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
->and($labels)->toContain('traefik.http.services.https-0-api-app-uuid.loadbalancer.server.port=8080');
|
||||||
|
|
||||||
|
$frontendPayload = $payloadMethod->invoke($manager, [
|
||||||
|
'channel_slug' => 'internal',
|
||||||
|
'app' => 'frontend',
|
||||||
|
'repository' => 'copenhagentruckwash/pleno-vue',
|
||||||
|
'branch' => 'master',
|
||||||
|
], [
|
||||||
|
'coolify_ports_exposes' => '80',
|
||||||
|
], 'https://api-v2.truckwash.io/internal/frontend', 'frontend-app-uuid', '');
|
||||||
|
$frontendLabels = explode("\n", base64_decode($frontendPayload['custom_labels'], true));
|
||||||
|
|
||||||
|
expect($frontendLabels)
|
||||||
|
->toContain('traefik.http.routers.https-0-frontend-app-uuid.middlewares=https-0-frontend-app-uuid-stripprefix,gzip');
|
||||||
|
expect(implode("\n", $frontendLabels))->not->toContain('-cors');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
|
it('updates existing frontend Coolify applications away from legacy Nixpacks detection', function (): void {
|
||||||
@@ -1560,12 +1584,13 @@ it('requires non-default release channel runtime URLs and preserves load balance
|
|||||||
'https://api-v2.truckwash.io/internal/api',
|
'https://api-v2.truckwash.io/internal/api',
|
||||||
'release-api-internal',
|
'release-api-internal',
|
||||||
80,
|
80,
|
||||||
'letsencrypt'
|
'letsencrypt',
|
||||||
|
'api'
|
||||||
);
|
);
|
||||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
|
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.rule=Host(`api-v2.truckwash.io`) && PathPrefix(`/internal/api`)');
|
||||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
|
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.priority=1013');
|
||||||
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
|
expect($labels)->toContain('traefik.http.middlewares.https-0-release-api-internal-stripprefix.stripprefix.prefixes=/internal/api');
|
||||||
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-stripprefix,gzip');
|
expect($labels)->toContain('traefik.http.routers.https-0-release-api-internal.middlewares=https-0-release-api-internal-cors,https-0-release-api-internal-stripprefix,gzip');
|
||||||
|
|
||||||
$source = file(app_path('classes/release_manager.php'));
|
$source = file(app_path('classes/release_manager.php'));
|
||||||
$methodSource = implode('', array_slice(
|
$methodSource = implode('', array_slice(
|
||||||
|
|||||||
Reference in New Issue
Block a user