Initialize merge conflict resolution plan

This commit is contained in:
copilot-swe-agent[bot]
2026-06-01 21:23:50 +00:00
committed by GitHub
15 changed files with 281 additions and 50 deletions
+7 -1
View File
@@ -1 +1,7 @@
/docker-compose.yml
/docker-compose.yml
# Runtime-generated replication bootstrap snapshots may contain infrastructure
# metadata and encrypted/plaintext credential material. They must be
# supplied at runtime via mounted storage, not baked into deployment images.
/services/nginx/app/storage/replication-bootstrap.json
/services/nginx/app/storage/replication-bootstrap-*.json
+4 -2
View File
@@ -46,6 +46,8 @@ jobs:
QODANA_TOKEN: ${{ secrets.QODANA_TOKEN }}
QODANA_ENDPOINT: 'https://qodana.cloud'
- name: 'Skip Qodana Scan (missing cloud token)'
- name: 'Qodana Scan (without cloud upload)'
if: ${{ steps.qodana-token.outputs.present != 'true' }}
run: echo "Skipping Qodana because QODANA_TOKEN is not configured for this repository."
uses: JetBrains/qodana-action@v2026.1
with:
pr-mode: false
+1
View File
@@ -51,6 +51,7 @@ COPY services/coolify/api/nginx.conf /etc/nginx/nginx.conf
COPY services/coolify/api/start.sh /usr/local/bin/coolify-api-start
RUN set -eux; \
rm -f /var/www/html/storage/replication-bootstrap.json /var/www/html/storage/replication-bootstrap-*.json; \
sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/coolify-api-start; \
COMPOSER_ALLOW_SUPERUSER=1 composer install --no-dev --prefer-dist --optimize-autoloader --no-interaction -d /var/www/html; \
+6
View File
@@ -10,9 +10,15 @@
# CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts.
<<<<<<< HEAD
# Replication bootstrap snapshots contain sensitive failover credentials.
@replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
respond @replication_bootstrap 404
=======
# Do not expose local replication bootstrap material from the public web root.
@replicationBootstrap path /storage/replication-bootstrap.json
respond @replicationBootstrap 404
>>>>>>> refs/remotes/origin/master
# PHP handling via FastCGI to php-fpm pool
php_fastcgi php1:9000 php2:9000 php3:9000 php4:9000 php5:9000
+6
View File
@@ -10,9 +10,15 @@
# CORS is handled at the edge by Traefik's headers middleware.
# Do not set or strip Access-Control-* headers here to avoid conflicts.
<<<<<<< HEAD
# Replication bootstrap snapshots contain sensitive failover credentials.
@replication_bootstrap path /storage/replication-bootstrap.json /storage/replication-bootstrap-*
respond @replication_bootstrap 404
=======
# Do not expose local replication bootstrap material from the public web root.
@replicationBootstrap path /storage/replication-bootstrap.json
respond @replicationBootstrap 404
>>>>>>> refs/remotes/origin/master
# PHP handling via FastCGI to php-fpm pool
php_fastcgi php-staging:9000
+31 -12
View File
@@ -58,6 +58,32 @@ function resolveAuthMode(options = {}, managerUrl = "") {
return "strict";
}
function resolveSharedSecret(options = {}) {
return String(options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "").trim();
}
function requireSharedSecret(req, res, sharedSecret) {
if (sharedSecret === "") {
jsonResponse(res, 503, {
ok: false,
error: "Edge broker shared secret is not configured",
shared_secret_required: true,
});
return false;
}
if (req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, {
ok: false,
error: "Forbidden",
shared_secret_required: true,
});
return false;
}
return true;
}
function parseScopes(value) {
if (!Array.isArray(value)) {
return [];
@@ -150,7 +176,7 @@ function rejectUpgrade(socket, statusCode, errorCode, message, details = {}) {
}
export function createBrokerServer(options = {}) {
const sharedSecret = options.sharedSecret ?? process.env.EDGE_BROKER_SHARED_SECRET ?? "";
const sharedSecret = resolveSharedSecret(options);
const managerUrl = resolveManagerUrl(options);
const authMode = resolveAuthMode(options, managerUrl);
const commandTimeoutMs = options.commandTimeoutMs ?? 10000;
@@ -460,25 +486,19 @@ export function createBrokerServer(options = {}) {
}
if (req.method === "POST" && url.pathname === "/api/diagnostics/shared-secret") {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, {
ok: false,
error: "Forbidden",
shared_secret_required: true,
});
if (!requireSharedSecret(req, res, sharedSecret)) {
return;
}
jsonResponse(res, 200, {
ok: true,
shared_secret_required: Boolean(sharedSecret),
shared_secret_required: true,
});
return;
}
if (req.method === "POST" && /^\/api\/gateways\/\d+\/commands$/.test(url.pathname)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, { error: "Forbidden" });
if (!requireSharedSecret(req, res, sharedSecret)) {
return;
}
@@ -521,8 +541,7 @@ export function createBrokerServer(options = {}) {
}
if (req.method === "POST" && /^\/api\/gateways\/\d+\/sync$/.test(url.pathname)) {
if (sharedSecret && req.headers["x-edge-broker-secret"] !== sharedSecret) {
jsonResponse(res, 403, { error: "Forbidden" });
if (!requireSharedSecret(req, res, sharedSecret)) {
return;
}
+47
View File
@@ -116,6 +116,53 @@ test("broker defaults to strict auth and fails closed when manager URL is missin
}
});
test("broker rejects protected HTTP endpoints when shared secret is missing", async () => {
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "", commandTimeoutMs: 2000 });
const address = await broker.listen(0);
const port = address.port;
const agent = new WebSocket(`ws://127.0.0.1:${port}/ws/agent?gatewayId=701&token=agent-token`);
await new Promise((resolve) => agent.once("open", resolve));
const agentMessages = collectMessages(agent);
const commandResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/commands`, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: JSON.stringify({
commandType: "SET_RELAY_STATE",
payload: { relayId: "M-7", on: true },
}),
});
const commandJson = await commandResponse.json();
assert.equal(commandResponse.status, 503);
assert.equal(commandJson.ok, false);
assert.equal(commandJson.shared_secret_required, true);
assert.match(commandJson.error, /shared secret is not configured/);
assert.equal(agentMessages.some((message) => message.type === "COMMAND"), false);
const diagnosticsResponse = await fetch(`http://127.0.0.1:${port}/api/diagnostics/shared-secret`, {
method: "POST",
});
const diagnosticsJson = await diagnosticsResponse.json();
assert.equal(diagnosticsResponse.status, 503);
assert.equal(diagnosticsJson.shared_secret_required, true);
const syncResponse = await fetch(`http://127.0.0.1:${port}/api/gateways/701/sync`, {
method: "POST",
});
const syncJson = await syncResponse.json();
assert.equal(syncResponse.status, 503);
assert.equal(syncJson.shared_secret_required, true);
agent.terminate();
await broker.close();
});
test("broker dispatches commands to connected agents", async () => {
const broker = createBrokerServer({ authMode: "stub", sharedSecret: "secret", commandTimeoutMs: 2000 });
const address = await broker.listen(0);
@@ -112,14 +112,19 @@ class edge_broker_client
throw new Exception('Edge broker URL is not configured');
}
$sharedSecret = $this->resolveSharedSecret();
if ($sharedSecret === '') {
throw new Exception('Edge broker shared secret is not configured');
}
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_TIMEOUT, $this->timeoutSeconds);
curl_setopt($ch, CURLOPT_HTTPHEADER, array_values(array_filter([
curl_setopt($ch, CURLOPT_HTTPHEADER, [
'Content-Type: application/json',
$this->resolveSharedSecret() !== '' ? 'X-Edge-Broker-Secret: ' . $this->resolveSharedSecret() : null,
])));
'X-Edge-Broker-Secret: ' . $sharedSecret,
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload, JSON_UNESCAPED_UNICODE));
$rawResponse = curl_exec($ch);
+87 -11
View File
@@ -35,6 +35,9 @@ class release_manager
private const DEFAULT_COOLIFY_ENVIRONMENT_CHANNELS = ['stable', 'production', 'prod'];
private const DEFAULT_COOLIFY_APPLICATION_PORT = '80';
private const DEFAULT_COOLIFY_API_DOCKERFILE = '/Dockerfile.coolify-api';
private const RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES = ['truckwash.io'];
private const RELEASE_GATE_MAX_PATHS = 10;
private const RELEASE_GATE_MAX_ASSETS = 50;
private const RELEASE_API_RUNTIME_ENV_KEYS = [
'USE_ENV',
'DEBUG',
@@ -1031,9 +1034,10 @@ class release_manager
'api_ping_paths' => $this->releaseGateStringArray(
$input['api_ping_paths']
?? $input['api_paths']
?? ['/master/api/ping']
?? ['/master/api/ping'],
self::RELEASE_GATE_MAX_PATHS
),
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash']),
'shell_paths' => $this->releaseGateStringArray($input['shell_paths'] ?? ['/', '/guest/book/wash'], self::RELEASE_GATE_MAX_PATHS),
];
}
@@ -1133,7 +1137,7 @@ class release_manager
return $steps;
}
private function releaseGateStringArray(mixed $value): array
private function releaseGateStringArray(mixed $value, int $limit = 50): array
{
if (is_string($value)) {
$value = preg_split('/\s*,\s*/', trim($value)) ?: [];
@@ -1149,7 +1153,7 @@ class release_manager
$values[] = $item;
}
}
return $values;
return array_slice($values, 0, max(0, $limit));
}
private function normalizeReleaseGateUrl(string $value): string
@@ -1389,14 +1393,14 @@ class release_manager
}
}
$assetUrls = $this->releaseGateUniqueStrings(array_merge(
$assetUrls = array_slice($this->releaseGateUniqueStrings(array_merge(
['release-manifest.json', 'release-entry.json'],
[(string)($manifestData['entry'] ?? '')],
is_array($manifestData['css'] ?? null) ? $manifestData['css'] : [],
is_array($manifestData['index_asset_urls'] ?? null) ? $manifestData['index_asset_urls'] : [],
is_array($manifestData['pwa_asset_urls'] ?? null) ? $manifestData['pwa_asset_urls'] : [],
is_array($manifestData['asset_urls'] ?? null) ? $manifestData['asset_urls'] : []
));
)), 0, self::RELEASE_GATE_MAX_ASSETS);
$verifiedAssets = 0;
foreach ($assetUrls as $assetUrl) {
if ($assetUrl === '/index.html') {
@@ -1515,15 +1519,18 @@ class release_manager
private function releaseGateFetch(string $url): array
{
$this->assertReleaseGateFetchUrlAllowed($url);
$curl = curl_init($url);
if ($curl === false) {
throw new RuntimeException('Could not initialize release gate request.');
}
curl_setopt($curl, CURLOPT_RETURNTRANSFER, true);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, true);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 5);
curl_setopt($curl, CURLOPT_TIMEOUT, 15);
curl_setopt($curl, CURLOPT_FOLLOWLOCATION, false);
curl_setopt($curl, CURLOPT_MAXREDIRS, 0);
curl_setopt($curl, CURLOPT_CONNECTTIMEOUT, 3);
curl_setopt($curl, CURLOPT_TIMEOUT, 8);
curl_setopt($curl, CURLOPT_NOSIGNAL, true);
curl_setopt($curl, CURLOPT_HTTPHEADER, [
'Accept: application/json, text/html, */*',
@@ -1552,13 +1559,82 @@ class release_manager
private function releaseGateJoinUrl(string $baseUrl, string $path): string
{
if (preg_match('#^https?://#i', $path) === 1) {
return $path;
$path = trim($path);
$parts = parse_url($path);
if (is_array($parts) && (!empty($parts['scheme']) || !empty($parts['host']))) {
throw new RuntimeException('Release gate paths must be relative to the configured Truckwash release host.');
}
if (str_starts_with($path, '//')) {
throw new RuntimeException('Release gate paths must not be protocol-relative URLs.');
}
return rtrim($baseUrl, '/') . '/' . ltrim($path, '/');
}
private function assertReleaseGateFetchUrlAllowed(string $url): void
{
$parts = parse_url($url);
$scheme = strtolower((string)($parts['scheme'] ?? ''));
$host = strtolower(rtrim((string)($parts['host'] ?? ''), '.'));
if (!in_array($scheme, ['http', 'https'], true) || $host === '') {
throw new RuntimeException('Release gate checks may only fetch HTTP(S) URLs from Truckwash release hosts.');
}
if (!$this->releaseGateFetchHostAllowed($host)) {
throw new RuntimeException('Release gate checks may only fetch configured Truckwash release hosts.');
}
$addresses = $this->releaseGateResolveHost($host);
if ($addresses === []) {
throw new RuntimeException('Release gate host could not be resolved.');
}
foreach ($addresses as $address) {
if (!$this->releaseGatePublicIpAllowed($address)) {
throw new RuntimeException('Release gate host resolved to a private, loopback, or reserved address.');
}
}
}
private function releaseGateFetchHostAllowed(string $host): bool
{
$host = strtolower(rtrim($host, '.'));
foreach (self::RELEASE_GATE_ALLOWED_FETCH_HOST_SUFFIXES as $allowedSuffix) {
$allowedSuffix = strtolower($allowedSuffix);
if ($host === $allowedSuffix || str_ends_with($host, '.' . $allowedSuffix)) {
return true;
}
}
return false;
}
private function releaseGateResolveHost(string $host): array
{
if (filter_var($host, FILTER_VALIDATE_IP) !== false) {
return [$host];
}
$addresses = gethostbynamel($host) ?: [];
if (function_exists('dns_get_record')) {
foreach (dns_get_record($host, DNS_AAAA) ?: [] as $record) {
if (is_array($record) && !empty($record['ipv6'])) {
$addresses[] = (string)$record['ipv6'];
}
}
}
return array_values(array_unique(array_filter($addresses, fn(string $address): bool => filter_var($address, FILTER_VALIDATE_IP) !== false)));
}
private function releaseGatePublicIpAllowed(string $address): bool
{
return filter_var(
$address,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
) !== false;
}
private function releaseGateCommitMatches(string $actual, string $expected): bool
{
$expected = strtolower(trim($expected));
+16 -15
View File
@@ -5,6 +5,21 @@ $isPreview = $_GET['preview'] ?? false;
// Remove query string if present
$file = strtok($file, '?');
// Require authentication for direct /files/ access
if (str_contains($file, '/files/')) {
$headers = getallheaders();
$token = $_GET['token'] ?? $_POST['token'] ?? ($headers['Authorization'] ?? null);
if (!empty($token)) {
$token = str_replace('Bearer ', '', $token);
}
if (empty($token) || !(new \classes\authentication())->validate_token($token)) {
header('HTTP/1.1 401 Unauthorized');
echo 'Unauthorized';
exit;
}
}
$isPDF = false;
$isPDFStore = false;
$isAttachment = false;
@@ -40,20 +55,6 @@ if ($isPDF && $isPDFStore) {
// Check if the certificate exists
if (!$wash_certificate_store->isFileInStore($file)) {
// Try the PDF store
$pdf_store = new \classes\pdf_store();
if ($pdf_store->isFileInStore(str_replace('/files/', '', $file))) {
// Download the certificate from the PDF store to /tmp
$certificate_path = $pdf_store->download(str_replace('/files/', '', $file));
// Send the certificate to the client
header('Content-Type: application/pdf');
header('Content-Disposition: inline; filename="' . str_replace('/files/', '', $file) . '"');
header('Content-Length: ' . filesize($certificate_path));
readfile($certificate_path);
// Delete the certificate from /tmp after sending it
unlink($certificate_path);
exit;
}
echo 'Certificate not found in store' . $file;
//header('HTTP/1.1 404 Not Found');
exit;
@@ -118,4 +119,4 @@ if (!$isPDF) {
// Delete the file from /tmp after sending it
unlink($file_path);
exit;
}
}
-5
View File
@@ -237,11 +237,6 @@ if (php_sapi_name() === 'cli' || isset($_GET['internalCronCall'])) {
exit;
}
// If the route ends with .php, then require the file_server.php
if (str_contains($_SERVER['REQUEST_URI'], '.pdf')) {
require_once 'file_server.php';
exit;
}
// If the route ends with a MIME type, then require the file_server.php
if ((preg_match('/\.(jpg|jpeg|png)$/', $_SERVER['REQUEST_URI']) || str_contains($_SERVER['REQUEST_URI'], '/files/'))) {
require_once 'file_server.php';
@@ -124,7 +124,7 @@ trait selfserve_lane_relay_controller_t
*/
public function setMachineRelayStatusHard(bool $on): bool
{
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE_PROGRAM_PICKER, $on);
return $this->setRelayStatusHard(selfserve_lane_relay::MACHINE, $on);
}
/**
@@ -1446,3 +1446,34 @@ it('normalizes release assignment subject suggestions without leaking private fi
'title' => 'Invalid',
]))->toBeNull();
});
it('restricts release gate fetches to Truckwash release hosts and relative paths', function (): void {
$manager = new release_manager();
$joinUrl = new ReflectionMethod(release_manager::class, 'releaseGateJoinUrl');
$joinUrl->setAccessible(true);
$hostAllowed = new ReflectionMethod(release_manager::class, 'releaseGateFetchHostAllowed');
$hostAllowed->setAccessible(true);
$publicIpAllowed = new ReflectionMethod(release_manager::class, 'releaseGatePublicIpAllowed');
$publicIpAllowed->setAccessible(true);
$stringArray = new ReflectionMethod(release_manager::class, 'releaseGateStringArray');
$stringArray->setAccessible(true);
expect($joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '/master/api/ping'))
->toBe('https://api-v2.truckwash.io/master/api/ping')
->and($hostAllowed->invoke($manager, 'api-v2.truckwash.io'))->toBeTrue()
->and($hostAllowed->invoke($manager, 'assets.canary.truckwash.io'))->toBeTrue()
->and($hostAllowed->invoke($manager, 'truckwash.io.evil.test'))->toBeFalse()
->and($hostAllowed->invoke($manager, '127.0.0.1'))->toBeFalse()
->and($publicIpAllowed->invoke($manager, '8.8.8.8'))->toBeTrue()
->and($publicIpAllowed->invoke($manager, '127.0.0.1'))->toBeFalse()
->and($publicIpAllowed->invoke($manager, '10.0.0.5'))->toBeFalse()
->and($publicIpAllowed->invoke($manager, '169.254.169.254'))->toBeFalse()
->and($publicIpAllowed->invoke($manager, '::1'))->toBeFalse()
->and($stringArray->invoke($manager, ['/a', '/b', '/c'], 2))->toBe(['/a', '/b']);
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', 'http://127.0.0.1/ping'))
->toThrow(RuntimeException::class, 'relative');
expect(fn() => $joinUrl->invoke($manager, 'https://api-v2.truckwash.io', '//127.0.0.1/ping'))
->toThrow(RuntimeException::class, 'relative');
});
@@ -11,3 +11,25 @@ it('uses default broker url but requires explicit shared secret configuration',
expect($source)->toContain("getenv('EDGE_INTERNAL_SECRET')");
expect($source)->not->toContain('DEFAULT_SHARED_SECRET');
});
it('fails closed when no broker shared secret is configured', function (): void {
$previousBrokerSecret = getenv('EDGE_BROKER_SHARED_SECRET');
$previousInternalSecret = getenv('EDGE_INTERNAL_SECRET');
putenv('EDGE_BROKER_SHARED_SECRET');
putenv('EDGE_INTERNAL_SECRET');
try {
$client = new \classes\edge_broker_client('http://127.0.0.1:9', null, 1);
expect(fn() => $client->dispatchCommand(1, 'SET_RELAY_STATE', ['on' => true]))
->toThrow(Exception::class, 'Edge broker shared secret is not configured');
} finally {
$previousBrokerSecret === false
? putenv('EDGE_BROKER_SHARED_SECRET')
: putenv('EDGE_BROKER_SHARED_SECRET=' . $previousBrokerSecret);
$previousInternalSecret === false
? putenv('EDGE_INTERNAL_SECRET')
: putenv('EDGE_INTERNAL_SECRET=' . $previousInternalSecret);
}
});
@@ -332,6 +332,20 @@ it('keeps back-to-back get/switch requests ordered through the relay controller'
expect($harness->shellyCalls[1]['endpoint'])->toBe('/v2/devices/api/set/switch');
});
it('hard MACHINE relay helper targets the configured MACHINE relay', function (): void {
$harness = selfserve_lane_shelly_test_harness();
$result = $harness->setMachineRelayStatusHard(true);
expect($result)->toBeTrue();
expect($harness->getShellyCallCount('/v2/devices/api/set/switch'))->toBe(1);
expect($harness->shellyCalls[0]['payload'])->toMatchArray([
'id' => 'relay-machine',
'channel' => 0,
'on' => true,
]);
});
it('executes sequential switch requests used by wash start and stop flows', function (): void {
$harness = selfserve_lane_shelly_test_harness();