diff --git a/.ai/lessons.md b/.ai/lessons.md index f1c8347295..130a87bc6b 100644 --- a/.ai/lessons.md +++ b/.ai/lessons.md @@ -19,7 +19,7 @@ - SSH retries replay the full command batch. Only exit code 255 is an SSH transport failure; other exit codes come from the remote command, so do not retry them. Keep batches safe to run twice (for example `docker rm -f || true` before `docker run --name `). - Do not put `pkill -f "playwright run-server"` in the same shell command as other work; the pattern matches that shell and kills it. Run it as its own command. - Redirect browser test output to a file (`> /tmp/x.log 2>&1`); piping it (`| tail`) hangs because the Playwright server keeps the pipe open. -- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. +- Browser `click()`/`assertSee()` wait forever when nothing matches, so a bad selector hangs the run instead of failing it. `click('Text')` can match a hidden element first (use `button:visible:has-text("Text")`); `:text-is()` on an element with child elements and `assertSee()` text with double quotes never match. Prefer plain CSS selectors. `click()` also hung on a button far down the long `/analytics` page; click it with `$page->script("document.querySelector('...').click()")` there. - A new worktree has no `vendor/` or `public/build/`. Run `composer install --ignore-platform-req=ext-zip` on the host and copy `public/build` from the main checkout before page-rendering tests; otherwise they fail with a Vite manifest error. - Never run `php artisan test` inside a dev container (`./scripts/dev exec` or `docker exec coolify-dev-*`). The container sets `DB_CONNECTION=pgsql` as a real environment variable, which wins over `phpunit.xml`, so `RefreshDatabase` runs `migrate:fresh` on the dev database and wipes it. Run tests on the host only. - The helper image runs `docker compose` for deployments and for the Coolify upgrade itself. Before you bump its Docker CLI or Compose, test them against the oldest supported Docker Engine (for example a `docker:-dind` container); newer clients can reject data from older daemons. diff --git a/app/Http/Controllers/Api/ApplicationsController.php b/app/Http/Controllers/Api/ApplicationsController.php index 5915724f1e..608e88a4c2 100644 --- a/app/Http/Controllers/Api/ApplicationsController.php +++ b/app/Http/Controllers/Api/ApplicationsController.php @@ -3818,7 +3818,7 @@ class ApplicationsController extends Controller // Must run after fqdn is filled: flags are kept only for domains the app still has. $application->setNoindexDomains($request->input('noindex_domains') ?? []); } - if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth) && $server->isProxyShouldRun()) { + if ($application->settings->is_container_label_readonly_enabled && ($requestHasDomains || $requestHasNoindexDomains || $requestHasHttpBasicAuth || $application->isDirty('ports_exposes')) && $server->isProxyShouldRun()) { $application->custom_labels = str(implode('|coolify|', generateLabelsApplication($application)))->replace('|coolify|', "\n"); } $application->withoutAuditLogging(fn () => $application->save()); diff --git a/bootstrap/helpers/shared.php b/bootstrap/helpers/shared.php index 0ca4cfe922..d4e1a03390 100644 --- a/bootstrap/helpers/shared.php +++ b/bootstrap/helpers/shared.php @@ -5871,6 +5871,17 @@ function deviceLabel(?string $device): string }; } +/** + * Whether a raw User-Agent comes from container tooling (Docker CLI and Engine, + * BuildKit, containerd, Podman, Skopeo) instead of a person or a bot. The + * analytics views hide these rows behind a toggle: image pulls and builds are + * normal, but the raw strings look like an attack or a leak of internal data. + */ +function isContainerToolingUserAgent(?string $userAgent): bool +{ + return preg_match('#^(docker|docker-client|buildkit|containerd|podman|skopeo|cri-o)/#i', trim((string) $userAgent)) === 1; +} + /** * Prepare aggregated `city` breakdown rows (`CC|subdivision|City`, from * TrafficAnalyticsAggregator::breakdowns(), not raw Sentinel rows) for display, diff --git a/resources/js/traffic-table.js b/resources/js/traffic-table.js index 0012b65d52..c917b49655 100644 --- a/resources/js/traffic-table.js +++ b/resources/js/traffic-table.js @@ -2,7 +2,8 @@ // its sort values as JSON in `data-row`. Rows are moved in the DOM, so keyboard // and screen-reader order match the visual order. A Livewire morph puts the rows // back in server order (they are keyed) and can add or remove rows, so the rows -// are sorted again after it. +// are sorted again after it. Rows marked `data-hidable` (container tooling user +// agents) are left out of the sort and the pages until `showHidden` is on. function compareValues(a, b) { if (typeof a === 'number' && typeof b === 'number') { return a - b; @@ -23,6 +24,8 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) { key, dir, tick: 0, + showHidden: false, + hiddenCount: 0, init() { this.resort(); @@ -65,9 +68,19 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) { this.resort(); }, + toggleHidden() { + this.showHidden = !this.showHidden; + this.page = 0; + this.resort(); + }, + resort() { const sign = this.dir === 'asc' ? 1 : -1; - const rows = [...this.$root.querySelectorAll('[data-row]')].map((el, index) => { + const all = [...this.$root.querySelectorAll('[data-row]')]; + const hidable = all.filter((el) => 'hidable' in el.dataset); + hidable.forEach((el) => ranks.delete(el)); + this.hiddenCount = hidable.length; + const rows = all.filter((el) => this.showHidden || !('hidable' in el.dataset)).map((el, index) => { let values = {}; try { values = JSON.parse(el.dataset.row) || {}; @@ -97,7 +110,10 @@ function trafficTable({ per = 10, key = 'requests', dir = 'desc' } = {}) { shown(el) { this.tick; - const rank = ranks.get(el) ?? 0; + const rank = ranks.get(el); + if (rank === undefined) { + return false; + } return rank >= this.page * this.per && rank < (this.page + 1) * this.per; }, diff --git a/resources/views/livewire/traffic/_breakdown-section.blade.php b/resources/views/livewire/traffic/_breakdown-section.blade.php index df862b9ec1..212e86833e 100644 --- a/resources/views/livewire/traffic/_breakdown-section.blade.php +++ b/resources/views/livewire/traffic/_breakdown-section.blade.php @@ -2,7 +2,8 @@ One breakdown dimension rendered as a sortable, paginated settings-section. Handles the per-dimension presentation: referrers get a DuckDuckGo favicon + an openable external link (rel="noopener noreferrer nofollow", new tab), devices are mapped - to friendly capitalized labels, and Sentinel's `__other__` overflow bucket reads + to friendly capitalized labels, container tooling user agents (Docker, BuildKit, + …) are hidden behind a toggle, and Sentinel's `__other__` overflow bucket reads as "Other". Expects `$dimension`, `$label`, `$rows` in scope; optional `$helper`. @param string $dimension Sentinel dimension key (referer, device, agent, …) @@ -32,6 +33,7 @@ @php $value = (string) ($row['value'] ?? ''); $isOther = $value === '__other__'; + $isHidable = $dimension === 'useragent' && isContainerToolingUserAgent($value); $host = ! $isOther && $dimension === 'referer' ? refererHost($value) : null; $display = $isOther ? 'Other' @@ -46,7 +48,7 @@ $width = min(100, round(($requests / $maxRequests) * 100, 1)); @endphp
@if ($dimension === 'referer' && $host) @@ -66,6 +68,12 @@
@endforeach +
+ +
@include('livewire.traffic._pager') @endif diff --git a/tests/Feature/Api/ApplicationPortsExposesLabelsApiTest.php b/tests/Feature/Api/ApplicationPortsExposesLabelsApiTest.php new file mode 100644 index 0000000000..305861a7d3 --- /dev/null +++ b/tests/Feature/Api/ApplicationPortsExposesLabelsApiTest.php @@ -0,0 +1,78 @@ + InstanceSettings::firstOrCreate(['id' => 0])); + + $this->team = Team::factory()->create(); + $this->user = User::factory()->create(); + $this->team->members()->attach($this->user->id, ['role' => 'owner']); + session(['currentTeam' => $this->team]); + + $plainTextToken = Str::random(40); + $token = $this->user->tokens()->create([ + 'name' => 'ports-exposes-api-test-'.Str::random(6), + 'token' => hash('sha256', $plainTextToken), + 'abilities' => ['*'], + 'team_id' => $this->team->id, + ]); + $this->bearerToken = $token->getKey().'|'.$plainTextToken; + + $this->server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::where('server_id', $this->server->id)->first(); + $environment = Environment::factory()->create([ + 'project_id' => Project::factory()->create(['team_id' => $this->team->id])->id, + ]); + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => $destination->getMorphClass(), + 'fqdn' => 'https://app.example.com', + 'ports_exposes' => '80', + ]); + $this->application->settings->update(['is_container_label_readonly_enabled' => true]); + $this->application->update([ + 'custom_labels' => base64_encode(implode("\n", generateLabelsApplication($this->application->fresh()))), + ]); +}); + +function patchPortsExposes(string $bearerToken, string $uuid, array $payload) +{ + return test()->withHeaders(['Authorization' => 'Bearer '.$bearerToken]) + ->patchJson("/api/v1/applications/{$uuid}", $payload); +} + +test('regenerates the load balancer port label when ports_exposes changes', function () { + expect((string) $this->application->fresh()->parseContainerLabels()) + ->toContain('loadbalancer.server.port=80'); + + patchPortsExposes($this->bearerToken, $this->application->uuid, ['ports_exposes' => '9000']) + ->assertOk(); + + $labels = (string) $this->application->fresh()->parseContainerLabels(); + + expect($labels)->toContain('loadbalancer.server.port=9000') + ->not->toContain('loadbalancer.server.port=80'); +}); + +test('keeps custom labels when readonly labels are disabled', function () { + $this->application->settings->update(['is_container_label_readonly_enabled' => false]); + $this->application->update(['custom_labels' => base64_encode('my.custom=label')]); + + patchPortsExposes($this->bearerToken, $this->application->uuid, ['ports_exposes' => '9000']) + ->assertOk(); + + expect((string) $this->application->fresh()->parseContainerLabels())->toBe('my.custom=label'); +}); diff --git a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php index 54fd736d6f..203a87a02d 100644 --- a/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php +++ b/tests/Feature/TrafficAnalytics/AnalyticsFormattingTest.php @@ -73,3 +73,25 @@ it('distinguishes a missing response status from numeric values in other dimensi 'overflow status' => ['status', '__other__', 'Other'], 'zero in another dimension' => ['path', '0', '0'], ]); + +it('detects container tooling user agents', function (?string $userAgent, bool $expected) { + expect(isContainerToolingUserAgent($userAgent))->toBe($expected); +})->with([ + 'buildkit' => ['buildkit/v0.33', true], + 'docker with compose upstream' => ['docker/29.6.2 go/go1.26.5 git-commit/3d80467 kernel/6.1.0-51-amd64 os/linux arch/amd64 UpstreamClient(compose/v5.3.1)', true], + 'docker-client' => ['docker-client/1.0', true], + 'containerd' => ['containerd/2.3.3+unknown', true], + 'podman' => ['podman/5.4.0', true], + 'skopeo' => ['skopeo/1.18.0', true], + 'cri-o' => ['cri-o/1.32.1', true], + 'mixed case' => ['Docker/29.6.2', true], + 'leading whitespace' => [' buildkit/v0.33', true], + 'docker without slash' => ['docker', false], + 'docker as a later token' => ['curl/8.5.0 docker/29.6.2', false], + 'docker prefix of another name' => ['dockerd/29.6.2', false], + 'firefox' => ['Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:156.0) Gecko/20100101 Firefox/156.0', false], + 'curl' => ['curl/8.5.0', false], + 'overflow bucket' => ['__other__', false], + 'empty' => ['', false], + 'null' => [null, false], +]); diff --git a/tests/v4/Browser/TrafficUserAgentToolingTest.php b/tests/v4/Browser/TrafficUserAgentToolingTest.php new file mode 100644 index 0000000000..fea5f00531 --- /dev/null +++ b/tests/v4/Browser/TrafficUserAgentToolingTest.php @@ -0,0 +1,85 @@ + [], + '/traffic/overview' => [ + 'requests' => 900, 'bytes_in' => 5000, 'bytes_out' => 12000, + 'status' => ['s2xx' => 900, 's3xx' => 0, 's4xx' => 0, 's5xx' => 0], + 'latency' => ['p50' => 12.5, 'p95' => 45.2, 'p99' => 90.1], + 'unique_visitors' => 320, + ], + '/breakdown/useragent' => [ + ['value' => 'Mozilla/5.0 Firefox/156.0', 'requests' => 654, 'bytes_out' => 1000], + ['value' => 'buildkit/v0.33', 'requests' => 138, 'bytes_out' => 0], + ['value' => 'docker/29.6.2 go/go1.26.5 os/linux', 'requests' => 63, 'bytes_out' => 500], + ], + ]; + + foreach ($responses as $needle => $response) { + if (str_contains($url, $needle)) { + return json_encode($response); + } + } + + return '{}'; + } +} + +beforeEach(function () { + config()->set('app.maintenance.store', 'array'); + + $this->stack = seedBrowserResourceStack(); + + $server = $this->stack['server']; + $server->settings->is_traffic_analytics_enabled = true; + $server->settings->save(); + + app()->bind(SentinelTrafficClient::class, fn () => new FakeBrowserUserAgentTrafficClient($server)); +}); + +/** + * The names of the visible user agent rows. + */ +function visibleUserAgentNames(mixed $page): array +{ + return $page->script(<<<'JS' + [...document.querySelectorAll('#analytics-useragent-section [data-row]')] + .filter((row) => getComputedStyle(row).display !== 'none') + .map((row) => JSON.parse(row.dataset.row).name) + JS); +} + +it('hides container tooling user agents until the toggle is selected', function () { + loginAndSkipBoarding(); + + $page = visit('/analytics'); + $toggle = '#analytics-useragent-section button[x-text]'; + + $page->assertSeeIn($toggle, 'Show 2 container tooling agents'); + expect(visibleUserAgentNames($page))->toBe(['Mozilla/5.0 Firefox/156.0']); + + $page->script("document.querySelector('{$toggle}').click()"); + $page->assertSeeIn($toggle, 'Hide container tooling') + ->wait(0.5); + expect(visibleUserAgentNames($page))->toBe([ + 'Mozilla/5.0 Firefox/156.0', + 'buildkit/v0.33', + 'docker/29.6.2 go/go1.26.5 os/linux', + ]); + + $page->script("document.querySelector('{$toggle}').click()"); + $page->assertSeeIn($toggle, 'Show 2 container tooling agents') + ->wait(0.5) + ->assertNoJavaScriptErrors() + ->screenshot(filename: 'analytics-useragent-tooling'); + expect(visibleUserAgentNames($page))->toBe(['Mozilla/5.0 Firefox/156.0']); +});