diff --git a/.github/workflows/pr-docs-tests.yml b/.github/workflows/pr-docs-tests.yml index 869d0acf6..7ba6ccea8 100644 --- a/.github/workflows/pr-docs-tests.yml +++ b/.github/workflows/pr-docs-tests.yml @@ -55,7 +55,7 @@ jobs: os: - ubuntu-24.04 node-version: - - "18" + - "20" steps: - name: Checkout code uses: actions/checkout@v4 diff --git a/CHANGELOG.md b/CHANGELOG.md index 272a00fbe..7d1a55ff4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,11 +1,13 @@ ## {{ UNRELEASED_VERSION }} - [{{ UNRELEASED_DATE }}]({{ UNRELEASED_LINK }}) + ### Fixes * Fixed bug causing https proxy routes to be assigned when they shouldnt be * Fixed bug causing cache from repopulating old proxy addresses that have been removed [#209](https://github.com/lando/core/issues/209) * Fixed bug in `v4` auto `entrypoint` and `command` population * Fixed regression causing empty tooling `options` to throw an error [#240](https://github.com/lando/core/issues/240) +* Improved merging of same-service, same-hostname-pathname `proxy` routes, fixes [#246](https://github.com/lando/core/issues/246) ## v3.23.0-beta.4 - [October 22, 2024](https://github.com/lando/core/releases/tag/v3.23.0-beta.4) diff --git a/examples/proxy/.lando.local.yml b/examples/proxy/.lando.local.yml new file mode 100644 index 000000000..d1f643e73 --- /dev/null +++ b/examples/proxy/.lando.local.yml @@ -0,0 +1,14 @@ +proxy: + web: + - hostname: lando-proxy.lndo.site + middlewares: + - name: test + key: headers.customresponseheaders.X-Lando-Merge + value: picard + - name: test + key: headers.customresponseheaders.X-Lando-Merge-XO + value: riker + +plugins: + "@lando/core": "../.." + "@lando/proxy": "../../plugins/proxy" diff --git a/examples/proxy/.lando.upstream.yml b/examples/proxy/.lando.upstream.yml new file mode 100644 index 000000000..f8eb4e6b3 --- /dev/null +++ b/examples/proxy/.lando.upstream.yml @@ -0,0 +1,11 @@ +proxy: + web: + - hostname: lando-proxy.lndo.site + middlewares: + - name: test + key: headers.customresponseheaders.X-Lando-Merge + value: kirk + +plugins: + "@lando/core": "../.." + "@lando/proxy": "../../plugins/proxy" diff --git a/examples/proxy/.lando.yml b/examples/proxy/.lando.yml index 515120891..8a2d62cbf 100644 --- a/examples/proxy/.lando.yml +++ b/examples/proxy/.lando.yml @@ -27,7 +27,7 @@ proxy: port: 8080 l337: - "give.me.*.lndo.site:8888/more/subs" - - "*.wild.l337.lndo.site:8888" + - hostname: "*.wild.l337.lndo.site:8888" - www.l337.lndo.site:8888 - hostname: l337.lndo.site port: 8888 diff --git a/examples/proxy/README.md b/examples/proxy/README.md index b1352754c..e67352a7e 100644 --- a/examples/proxy/README.md +++ b/examples/proxy/README.md @@ -105,6 +105,10 @@ lando exec web4 -- env | grep LANDO_SERVICE_CERT | grep /certs/cert.crt lando exec web4 -- cat \$LANDO_SERVICE_KEY lando exec web4 -- env | grep LANDO_SERVICE_KEY | grep /certs/cert.key +# Should succcesfully merge same-service same-hostname-pathname routes together correctly +lando exec php -- curl -sI http://lando-proxy.lndo.site | grep -i "X-Lando-Merge" | grep picard +lando exec php -- curl -sI http://lando-proxy.lndo.site | grep -i "X-Lando-Merge-Xo" | grep riker + # Should remove proxy entries when removed from the landofile and rebuild cp -rf .lando.yml .lando.old.yml cp -rf .lando.stripped.yml .lando.yml diff --git a/plugins/proxy/app.js b/plugins/proxy/app.js index dacb5e0f9..f5070d2d2 100644 --- a/plugins/proxy/app.js +++ b/plugins/proxy/app.js @@ -128,6 +128,12 @@ module.exports = (app, lando) => { // Parse the proxy config to get traefix labels .then(() => { + // normalize and merge proxy routes + app.config.proxy = utils.normalizeRoutes(app.config.proxy); + + // log error for any duplicates across services + // @NOTE: this actually just calculates ALL occurences of a given hostname and not within each service + // but with the deduping in normalizeRoutes it probably works well enough for right now const urlCounts = utils.getUrlsCounts(app.config.proxy); if (_.max(_.values(urlCounts)) > 1) { app.log.error('You cannot assign url %s to more than one service!', _.findKey(urlCounts, c => c > 1)); diff --git a/plugins/proxy/lib/utils.js b/plugins/proxy/lib/utils.js index 8c04a2b87..70d86da1c 100644 --- a/plugins/proxy/lib/utils.js +++ b/plugins/proxy/lib/utils.js @@ -5,6 +5,8 @@ const _ = require('lodash'); const hasher = require('object-hash'); const url = require('url'); +const merge = require('../../../utils/merge'); + /* * Helper to get URLs for app info and scanning purposes */ @@ -37,6 +39,50 @@ exports.needsProtocolScan = (current, last, status = {http: true, https: true}) return status; }; +/* + * Helper to parse a url + */ +exports.normalizeRoutes = (services = {}) => Object.fromEntries(_.map(services, (routes, key) => { + const defaults = {port: '80', pathname: '/', middlewares: []}; + + // normalize routes + routes = routes.map(route => { + // if route is a string then + if (typeof route === 'string') route = {hostname: route}; + + // url parse hostname with wildcard stuff if needed + route.hostname = route.hostname.replace(/\*/g, '__wildcard__'); + + // if hostname does not start with http:// or https:// then prepend http + // @TODO: does this allow for protocol selection down the road? + if (!route.hostname.startsWith('http://') || !route.hostname.startsWith('https://')) { + route.hostname = `http://${route.hostname}`; + } + + // at this point we should be able to parse the hostname + // @TODO: do we need to try/catch this? + const {hostname, port, pathname} = URL.parse(route.hostname); + + // and rebase the whole thing + route = merge({}, [defaults, {port: port === '' ? '80' : port, pathname}, route, {hostname}], ['merge:key', 'replace']); + + // wildcard replacement back + route.hostname = route.hostname.replace(/__wildcard__/g, '*'); + + // generate an id based on protocol/hostname/path so we can groupby and dedupe + route.id = hasher(`${route.hostname}-${route.pathname}`); + + // and return + return route; + }); + + // merge together all routes with the same id + routes = _(_.groupBy(routes, 'id')).map((routes, id) => merge({}, routes, ['merge:key', 'replace'])).value(); + + // return + return [key, routes]; +})); + /* * Helper to get proxy runner */ @@ -53,11 +99,17 @@ exports.getProxyRunner = (project, files) => ({ * Helper to get the trafix rule */ exports.getRule = rule => { + // we do this so getRule is backwards campatible with the older rule.host + const host = rule.hostname ?? rule.host; + // Start with the rule we can assume - const hostRegex = rule.host.replace(new RegExp('\\*', 'g'), '{wildcard:[a-z0-9-]+}'); + const hostRegex = host.replace(new RegExp('\\*', 'g'), '{wildcard:[a-z0-9-]+}'); const rules = [`HostRegexp(\`${hostRegex}\`)`]; + // Add in the path prefix if we can if (rule.pathname.length > 1) rules.push(`PathPrefix(\`${rule.pathname}\`)`); + + // return return rules.join(' && '); }; @@ -105,20 +157,18 @@ exports.parseConfig = (config, sslReady = []) => _(config) */ exports.parseRoutes = (service, urls = [], sslReady, labels = {}) => { // Prepare our URLs for traefik - const parsedUrls = _(urls) - .map(url => exports.parseUrl(url)) - .map(parsedUrl => _.merge({}, parsedUrl, {id: hasher(parsedUrl)})) - .uniqBy('id') - .value(); + const rules = _(urls).uniqBy('id').value(); // Add things into the labels - _.forEach(parsedUrls, rule => { + _.forEach(rules, rule => { // Add some default middleware rule.middlewares.push({name: 'lando', key: 'headers.customrequestheaders.X-Lando', value: 'on'}); + // Add in any path stripping middleware we need it if (rule.pathname.length > 1) { rule.middlewares.push({name: 'stripprefix', key: 'stripprefix.prefixes', value: rule.pathname}); }; + // Ensure we prefix all middleware with the ruleid rule.middlewares = _(rule.middlewares) .map(middleware => _.merge({}, middleware, {name: `${rule.id}-${middleware.name}`}))