diff --git a/.github/workflows/browser-tests.yaml b/.github/workflows/browser-tests.yaml index 1a74054..300b05a 100644 --- a/.github/workflows/browser-tests.yaml +++ b/.github/workflows/browser-tests.yaml @@ -103,3 +103,44 @@ jobs: AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + varnish9: + name: "Varnish 9 integration tests" + uses: ibexa/gh-workflows/.github/workflows/browser-tests.yml@main + with: + project-edition: 'oss' + setup: "doc/docker/base-dev.yml:doc/docker/varnish9.yml:doc/docker/selenium.yml" + test-suite: '--mode=standard --profile=httpCache --suite=varnish9' + test-setup-phase-1: '--mode=standard --profile=httpCache --suite=setup' + secrets: + AUTOMATION_CLIENT_ID: ${{ secrets.AUTOMATION_CLIENT_ID }} + AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} + AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + varnish9-translation-aware: + name: "Varnish 9 integration tests (translation-aware)" + uses: ibexa/gh-workflows/.github/workflows/browser-tests.yml@main + with: + project-edition: 'oss' + setup: "doc/docker/base-dev.yml:doc/docker/varnish9.yml:doc/docker/selenium.yml" + test-suite: '--mode=standard --profile=httpCache --suite=varnish9-translation-aware' + test-setup-phase-1: '--mode=standard --profile=httpCache --suite=setup-translation-aware' + test-setup-phase-2: '--mode=standard --profile=httpCache --suite=setup' + secrets: + AUTOMATION_CLIENT_ID: ${{ secrets.AUTOMATION_CLIENT_ID }} + AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} + AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} + varnish9-token: + name: "Varnish 9 integration tests with invalidate token" + uses: ibexa/gh-workflows/.github/workflows/browser-tests.yml@main + with: + project-edition: 'oss' + setup: "doc/docker/base-dev.yml:doc/docker/varnish9.yml:doc/docker/selenium.yml" + test-suite: '--mode=standard --profile=httpCache --suite=varnish9' + test-setup-phase-1: '--mode=standard --profile=httpCache --suite=setup-token' + test-setup-phase-2: '--mode=standard --profile=httpCache --suite=setup' + secrets: + AUTOMATION_CLIENT_ID: ${{ secrets.AUTOMATION_CLIENT_ID }} + AUTOMATION_CLIENT_INSTALLATION: ${{ secrets.AUTOMATION_CLIENT_INSTALLATION }} + AUTOMATION_CLIENT_SECRET: ${{ secrets.AUTOMATION_CLIENT_SECRET }} + SLACK_WEBHOOK_URL: ${{ secrets.SLACK_WEBHOOK_URL }} diff --git a/behat_suites.yml b/behat_suites.yml index c6a2d01..e162445 100644 --- a/behat_suites.yml +++ b/behat_suites.yml @@ -62,7 +62,37 @@ httpCache: paths: - '%paths.base%/vendor/ibexa/http-cache/features/varnish' filters: - tags: '@varnish7&&~@translationNotAware' + tags: '@varnish7&&~@translationNotAware' + contexts: + - Ibexa\Behat\API\Context\TestContext + - Ibexa\Behat\API\Context\ContentTypeContext + - Ibexa\Behat\Core\Context\TimeContext + - Ibexa\Behat\Core\Context\ConfigurationContext + - Ibexa\Behat\API\Context\ContentContext + - Ibexa\Behat\Browser\Context\BrowserContext + - Ibexa\Behat\Browser\Context\AuthenticationContext + - Behat\MinkExtension\Context\MinkContext + - Ibexa\Behat\Browser\Context\ContentPreviewContext + varnish9: + paths: + - '%paths.base%/vendor/ibexa/http-cache/features/varnish' + filters: + tags: '@varnish9&&~@translationAware' + contexts: + - Ibexa\Behat\API\Context\TestContext + - Ibexa\Behat\API\Context\ContentTypeContext + - Ibexa\Behat\Core\Context\TimeContext + - Ibexa\Behat\Core\Context\ConfigurationContext + - Ibexa\Behat\API\Context\ContentContext + - Ibexa\Behat\Browser\Context\BrowserContext + - Ibexa\Behat\Browser\Context\AuthenticationContext + - Behat\MinkExtension\Context\MinkContext + - Ibexa\Behat\Browser\Context\ContentPreviewContext + varnish9-translation-aware: + paths: + - '%paths.base%/vendor/ibexa/http-cache/features/varnish' + filters: + tags: '@varnish9&&~@translationNotAware' contexts: - Ibexa\Behat\API\Context\TestContext - Ibexa\Behat\API\Context\ContentTypeContext diff --git a/dependencies.json b/dependencies.json new file mode 100644 index 0000000..84c189e --- /dev/null +++ b/dependencies.json @@ -0,0 +1,11 @@ +{ + "recipesEndpoint": "", + "packages": [ + { + "requirement": "dev-IBX-12173_support_for_varnish9 as 4.6.x-dev", + "repositoryUrl": "https://github.com/ibexa/docker", + "package": "ibexa/docker", + "shouldBeAddedAsVCS": true + } + ] +} diff --git a/docs/varnish/vcl/varnish9.vcl b/docs/varnish/vcl/varnish9.vcl new file mode 100644 index 0000000..0f2105c --- /dev/null +++ b/docs/varnish/vcl/varnish9.vcl @@ -0,0 +1,318 @@ +// Varnish VCL for: +// - Varnish 9.0 or higher +// - Varnish xkey vmod (via varnish-modules package 0.28.0 or higher, or via Varnish Plus) +// +// +// Make sure to at least adjust default parameters.vcl, defaults there reflect our testing needs with docker. + +vcl 4.1; +import std; +import xkey; + +// For customizing your backend and acl rules see parameters.vcl +include "/etc/varnish/parameters.vcl"; + +// Called at the beginning of a request, after the complete request has been received +sub vcl_recv { + + // Set the backend + set req.backend_hint = ezplatform; + + // Add a Surrogate-Capability header to announce ESI support. + set req.http.Surrogate-Capability = "abc=ESI/1.0"; + + // Ensure that the Symfony Router generates URLs correctly with Varnish + if (req.http.X-Forwarded-Proto == "https" ) { + set req.http.X-Forwarded-Port = "443"; + } else { + set req.http.X-Forwarded-Port = "80"; + } + + // Trigger cache purge if needed + call ez_purge; + + // Don't cache requests other than GET and HEAD. + if (req.method != "GET" && req.method != "HEAD") { + return (pass); + } + + // Don't cache Authenticate & Authorization + // You may remove this when using REST API with basic auth. + if (req.http.Authenticate || req.http.Authorization) { + if (client.ip ~ debuggers) { + set req.http.X-Debug = "Not Cached according to configuration (Authorization)"; + } + return (hash); + } + + // Remove all cookies besides Session ID, as JS tracker cookies and so will make the responses effectively un-cached + if (req.http.cookie) { + set req.http.cookie = ";" + req.http.cookie; + set req.http.cookie = regsuball(req.http.cookie, "; +", ";"); + set req.http.cookie = regsuball(req.http.cookie, ";(eZSESSID[^=]*)=", "; \1="); + set req.http.cookie = regsuball(req.http.cookie, ";(ibexa-[^=]*)=", "; \1="); + set req.http.cookie = regsuball(req.http.cookie, ";[^ ][^;]*", ""); + set req.http.cookie = regsuball(req.http.cookie, "^[; ]+|[; ]+$", ""); + + if (req.http.cookie == "") { + // If there are no more cookies, remove the header to get page cached. + unset req.http.cookie; + } + } + + // Logged-in users must not be served stale content while the backend is healthy: drop their + // grace allowance so an expired object is refetched rather than delivered from the grace + // window. varnish5/6.vcl do this in vcl_hit with return (miss), which later versions do not support. + if (req.http.cookie && std.healthy(req.backend_hint)) { + set req.grace = 0s; + } + + // Do a standard lookup on assets (these don't vary by user context hash) + // Note that file extension list below is not extensive, so consider completing it to fit your needs. + if (req.url ~ "\.(css|js|gif|jpe?g|bmp|png|tiff?|ico|img|tga|wmf|svg|swf|ico|mp3|mp4|m4a|ogg|mov|avi|wmv|zip|gz|pdf|ttf|eot|wof)$") { + return (hash); + } + + // Sort the query string for cache normalization. + set req.url = std.querysort(req.url); + + // Retrieve client user context hash and add it to the forwarded request. + call ez_user_context_hash; + + // If it passes all these tests, do a lookup anyway. + return (hash); +} + +// Called when the requested object has been retrieved from the backend +sub vcl_backend_response { + + if (bereq.http.accept ~ "application/vnd.fos.user-context-hash" + && beresp.status >= 500 + ) { + return (abandon); + } + + // Check for ESI acknowledgement and remove Surrogate-Control header + if (beresp.http.Surrogate-Control ~ "ESI/1.0") { + unset beresp.http.Surrogate-Control; + set beresp.do_esi = true; + } + + // Make Varnish keep all objects for up to 1 hour beyond their TTL, see vcl_recv for Request logic on this + // This will make Varnish to refresh objects in cache after 1 hour + // The total time object can be in cache is 70 minutes (grace + keep time) + set beresp.grace = 1h; + set beresp.keep = 10m; + + // Compressing the content + if (beresp.http.Content-Type ~ "application/javascript" + || beresp.http.Content-Type ~ "application/vnd.ms-fontobject" + || beresp.http.Content-Type ~ "application/x-font-ttf" + || beresp.http.Content-Type ~ "image/svg+xml" + || beresp.http.Content-Type ~ "text/css" + || beresp.http.Content-Type ~ "text/plain" + ) { + set beresp.do_gzip = true; + } + + // Modify xkey header to add translation suffix + if (beresp.http.xkey && beresp.http.x-lang) { + set beresp.http.xkey = beresp.http.xkey + " " + regsuball(beresp.http.xkey, "(\S+)", "\1" + beresp.http.x-lang); + } +} + +// Handle purge +// You may add FOSHttpCacheBundle tagging rules +// See http://foshttpcache.readthedocs.org/en/latest/varnish-configuration.html#id4 +sub ez_purge { + // Retrieve purge token, needs to be here due to restart, match for PURGE method done within + call ez_invalidate_token; + + # Adapted with acl from vendor/friendsofsymfony/http-cache/resources/config/varnish/fos_tags_xkey.vcl + if (req.method == "PURGEKEYS") { + call ez_purge_acl; + + # If neither of the headers are provided we return 400 to simplify detecting wrong configuration + if (!req.http.xkey-purge && !req.http.xkey-softpurge) { + return (synth(400, "Neither header XKey-Purge or XKey-SoftPurge set")); + } + + # Based on provided header invalidate (purge) and/or expire (softpurge) the tagged content + set req.http.n-gone = 0; + set req.http.n-softgone = 0; + if (req.http.xkey-purge) { + set req.http.n-gone = xkey.purge(req.http.xkey-purge); + } + + if (req.http.xkey-softpurge) { + set req.http.n-softgone = xkey.softpurge(req.http.xkey-softpurge); + } + + return (synth(200, "Purged "+req.http.n-gone+" objects, expired "+req.http.n-softgone+" objects")); + } + + # Adapted with acl from vendor/friendsofsymfony/http-cache/resources/config/varnish/fos_purge.vcl + if (req.method == "PURGE") { + call ez_purge_acl; + + return (purge); + } +} + +sub ez_purge_acl { + if (req.http.x-invalidate-token) { + if (req.http.x-invalidate-token != req.http.x-backend-invalidate-token) { + return (synth(405, "Method not allowed")); + } + } else if (!client.ip ~ invalidators) { + return (synth(405, "Method not allowed")); + } +} + +// Sub-routine to get client user context hash, used to for being able to vary page cache on user rights. +sub ez_user_context_hash { + + // Prevent tampering attacks on the hash mechanism + if (req.restarts == 0 + && (req.http.accept ~ "application/vnd.fos.user-context-hash" + || req.http.x-user-context-hash + ) + ) { + return (synth(400)); + } + + if (req.restarts == 0 && (req.method == "GET" || req.method == "HEAD")) { + // Backup accept header, if set + if (req.http.accept) { + set req.http.x-fos-original-accept = req.http.accept; + } + set req.http.accept = "application/vnd.fos.user-context-hash"; + + // Backup original URL + set req.http.x-fos-original-url = req.url; + set req.url = "/_fos_user_context_hash"; + + // Force the lookup, the backend must tell not to cache or vary on all + // headers that are used to build the hash. + return (hash); + } + + // Rebuild the original request which now has the hash. + if (req.restarts > 0 + && req.http.accept == "application/vnd.fos.user-context-hash" + ) { + set req.url = req.http.x-fos-original-url; + unset req.http.x-fos-original-url; + if (req.http.x-fos-original-accept) { + set req.http.accept = req.http.x-fos-original-accept; + unset req.http.x-fos-original-accept; + } else { + // If accept header was not set in original request, remove the header here. + unset req.http.accept; + } + + // Force the lookup, the backend must tell not to cache or vary on the + // user context hash to properly separate cached data. + + return (hash); + } +} + +// Sub-routine to get invalidate token. +sub ez_invalidate_token { + // Prevent tampering attacks on the token mechanisms + if (req.restarts == 0 + && (req.http.accept ~ "application/vnd.ezplatform.invalidate-token" + || req.http.x-backend-invalidate-token + ) + ) { + return (synth(400)); + } + + if (req.restarts == 0 && (req.method == "PURGE" || req.method == "PURGEKEYS") && req.http.x-invalidate-token) { + set req.http.accept = "application/vnd.ezplatform.invalidate-token"; + + // Backup original http properties + set req.http.x-fos-token-url = req.url; + set req.http.x-fos-token-method = req.method; + + set req.url = "/_ibexa_http_invalidatetoken"; + + // Force the lookup + return (hash); + } + + // Rebuild the original request which now has the invalidate token. + if (req.restarts > 0 + && req.http.accept == "application/vnd.ezplatform.invalidate-token" + ) { + set req.url = req.http.x-fos-token-url; + set req.method = req.http.x-fos-token-method; + unset req.http.x-fos-token-url; + unset req.http.x-fos-token-method; + unset req.http.accept; + } +} + +sub vcl_deliver { + // On receiving the invalidate token response, copy the invalidate token to the original + // request and restart. + if (req.restarts == 0 + && resp.http.content-type ~ "application/vnd.ezplatform.invalidate-token" + ) { + set req.http.x-backend-invalidate-token = resp.http.x-invalidate-token; + + return (restart); + } + + // On receiving the hash response, copy the hash header to the original + // request and restart. + if (req.restarts == 0 + && resp.http.content-type ~ "application/vnd.fos.user-context-hash" + ) { + set req.http.x-user-context-hash = resp.http.x-user-context-hash; + + return (restart); + } + + // If we get here, this is a real response that gets sent to the client. + + // Remove the vary on user context hash, this is nothing public. Keep all + // other vary headers. + if (resp.http.Vary ~ "X-User-Context-Hash") { + set resp.http.Vary = regsub(resp.http.Vary, "(?i),? *X-User-Context-Hash *", ""); + set resp.http.Vary = regsub(resp.http.Vary, "^, *", ""); + if (resp.http.Vary == "") { + unset resp.http.Vary; + } + + // If we vary by user hash, we'll also adjust the cache control headers going out by default to avoid sending + // large ttl meant for Varnish to shared proxies and such. We assume only session cookie is left after vcl_recv. + if (req.http.cookie) { + // When in session where we vary by user hash we by default avoid caching this in shared proxies & browsers + // For browser cache with it revalidating against varnish, use for instance "private, no-cache" instead + set resp.http.cache-control = "private, no-cache, no-store, must-revalidate"; + } else if (resp.http.cache-control ~ "public") { + // For non logged in users we allow caching on shared proxies (mobile network accelerators, planes, ...) + // But only for a short while, as there is no way to purge them + set resp.http.cache-control = "public, s-maxage=600, stale-while-revalidate=300, stale-if-error=300"; + } + } + + if (client.ip ~ debuggers) { + // Add X-Cache header if debugging is enabled + if (obj.hits > 0) { + set resp.http.X-Cache = "HIT"; + set resp.http.X-Cache-Hits = obj.hits; + set resp.http.X-Cache-TTL = obj.ttl; + } else { + set resp.http.X-Cache = "MISS"; + } + } else { + // Remove tag headers when delivering to non debug client + unset resp.http.xkey; + unset resp.http.x-lang; + // Sanity check to prevent ever exposing the hash to a non debug client. + unset resp.http.x-user-context-hash; + } +} diff --git a/features/varnish/cache.feature b/features/varnish/cache.feature index 96d0909..c8ba689 100644 --- a/features/varnish/cache.feature +++ b/features/varnish/cache.feature @@ -1,4 +1,4 @@ -@varnish6 @varnish7 +@varnish6 @varnish7 @varnish9 Feature: As an site administrator I want my pages to be cached using Varnish @admin diff --git a/features/varnish/cacheKey.feature b/features/varnish/cacheKey.feature index bc28e85..d6cb14a 100644 --- a/features/varnish/cacheKey.feature +++ b/features/varnish/cacheKey.feature @@ -1,6 +1,6 @@ Feature: As a site administrator I want equivalent requests to share one cache object - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: Query string parameter order does not create a second cache object # vcl_recv normalises the cache key with std.querysort, so the two requests below # differ only in the order the parameters were written and must hit the same object diff --git a/features/varnish/embed.feature b/features/varnish/embed.feature index 9bef90a..95bf6a4 100644 --- a/features/varnish/embed.feature +++ b/features/varnish/embed.feature @@ -1,4 +1,4 @@ -@varnish6 @varnish7 +@varnish6 @varnish7 @varnish9 Feature: Caching of embedded items @admin diff --git a/features/varnish/permissions.feature b/features/varnish/permissions.feature index f0717a0..78fb1c4 100644 --- a/features/varnish/permissions.feature +++ b/features/varnish/permissions.feature @@ -1,4 +1,4 @@ -@varnish6 @varnish7 +@varnish6 @varnish7 @varnish9 Feature: Cached response is different for users with different permissions Scenario: Content Items are cached based on users permissions when directly visited diff --git a/features/varnish/tags.feature b/features/varnish/tags.feature index 75e4ca2..fe3e1d1 100644 --- a/features/varnish/tags.feature +++ b/features/varnish/tags.feature @@ -1,6 +1,6 @@ Feature: As a site administrator I want responses to carry the cache tags Varnish invalidates on - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: A Content view response is tagged with the content and location it was built from # Tags are what every purge in this bundle keys on, but they are otherwise only tested # indirectly - through whether a purge happened to work. fos_http_cache is configured diff --git a/features/varnish/translations.feature b/features/varnish/translations.feature index aae1dcd..943fe23 100644 --- a/features/varnish/translations.feature +++ b/features/varnish/translations.feature @@ -1,6 +1,6 @@ Feature: As an site administrator I want my pages to be cached using Varnish - @APIUser:admin @varnish6 @varnish7 + @APIUser:admin @varnish6 @varnish7 @varnish9 Scenario Outline: Correct translation is displayed when a new translation is published Given I create "Folder" Content items in root in "pol-PL" | name | short_name | @@ -35,7 +35,7 @@ Feature: As an site administrator I want my pages to be cached using Varnish | admin | publish | ItemPolskiAdmin | ItemEnglishAdmin | | anonymous | | ItemPolskiAnon | ItemEnglishAnon | - @APIUser:admin @javascript @translationNotAware @varnish6 + @APIUser:admin @javascript @translationNotAware @varnish6 @varnish7 @varnish9 Scenario: Main translation cache is purged when a fallback translation is edited Given I am viewing the pages on siteaccess "site" as "admin" with password "publish" And I create "embeddedContentType" Content items in root in "eng-GB" @@ -67,7 +67,7 @@ Feature: As an site administrator I want my pages to be cached using Varnish And I should see "EmbeddedTranslationEnglish" And the action took longer than 5 seconds - @APIUser:admin @javascript @translationAware @varnish6 @varnish7 + @APIUser:admin @javascript @translationAware @varnish6 @varnish7 @varnish9 Scenario: Main translation cache is not purged when a fallback translation is edited Given I am viewing the pages on siteaccess "site" as "admin" with password "publish" And I create "embeddedContentType" Content items in root in "eng-GB" diff --git a/features/varnish/ttl.feature b/features/varnish/ttl.feature index ca3420c..1966b40 100644 --- a/features/varnish/ttl.feature +++ b/features/varnish/ttl.feature @@ -5,7 +5,7 @@ Feature: As a site administrator I want Varnish to apply the configured TTL and # Keeping them separate means the timing of the other suites is untouched and the assertions # do not depend on whatever the default TTL happens to be. - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: The TTL Varnish stores an object with comes from content.default_ttl Given I create "Folder" Content items in root in "eng-GB" | name | short_name | @@ -27,7 +27,7 @@ Feature: As a site administrator I want Varnish to apply the configured TTL and | Header | Pattern | | x-cache-ttl | /^[0-4]\.\d+$/ | - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: Responses for anonymous users may be cached by shared proxies for a short while Given I create "Folder" Content items in root in "eng-GB" | name | short_name | @@ -41,7 +41,7 @@ Feature: As a site administrator I want Varnish to apply the configured TTL and | Header | Pattern | | vary | /^(?!.*X-User-Context-Hash).*$/i | - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: Responses for logged-in users are not cacheable by shared proxies or browsers Given I create "Folder" Content items in root in "eng-GB" | name | short_name | @@ -52,7 +52,7 @@ Feature: As a site administrator I want Varnish to apply the configured TTL and | Header | Value | | cache-control | private, no-cache, no-store, must-revalidate | - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: An expired object is served from grace and refreshed in the background Given I create "Folder" Content items in root in "eng-GB" | name | short_name | @@ -83,7 +83,7 @@ Feature: As a site administrator I want Varnish to apply the configured TTL and | Header | Pattern | | x-cache-ttl | /^[0-4]\.\d+$/ | - @varnish6 @varnish7 + @varnish6 @varnish7 @varnish9 Scenario: A logged-in user in the grace window gets a refreshed response, not a stale one # Editors must never be shown stale content. varnish5/6.vcl enforce that in vcl_hit with # return (miss); varnish7.vcl drops the grace allowance in vcl_recv instead, because later