diff --git a/bouncer/.gitignore b/bouncer/.gitignore index f6f898b..e6bde5c 100644 --- a/bouncer/.gitignore +++ b/bouncer/.gitignore @@ -1,3 +1,4 @@ -/docker-compose.override.yml -/vendor -/.php-cs-fixer.cache \ No newline at end of file +.idea +docker-compose.override.yml +vendor +.php-cs-fixer.cache \ No newline at end of file diff --git a/bouncer/Dockerfile b/bouncer/Dockerfile index a25034f..2c4c365 100644 --- a/bouncer/Dockerfile +++ b/bouncer/Dockerfile @@ -49,11 +49,13 @@ COPY composer.* /app/ COPY public /app/public RUN composer install && \ chmod +x /app/bouncer && \ - mkdir -p /var/log/bouncer + mkdir -p /var/log/bouncer && \ + rm /etc/nginx/sites-enabled/default && \ + cp /etc/nginx/sites-available/default /etc/nginx/sites-enabled/default -FROM benzine/php:nginx-8.0 as test-app-a +FROM benzine/php:nginx-8.1 as test-app-a COPY ./test/public-web-a /app/public -FROM benzine/php:nginx-8.0 as test-app-b +FROM benzine/php:nginx-8.1 as test-app-b COPY ./test/public-web-b /app/public diff --git a/bouncer/NginxTemplate.twig b/bouncer/NginxTemplate.twig index 84370e6..6387073 100644 --- a/bouncer/NginxTemplate.twig +++ b/bouncer/NginxTemplate.twig @@ -12,6 +12,9 @@ server { {% if useTemporaryCert %} ssl_certificate /certs/example.crt; ssl_certificate_key /certs/example.key; +{% elseif useGlobalCert %} + ssl_certificate /certs/global.crt; + ssl_certificate_key /certs/global.key; {% else %} ssl_certificate /etc/letsencrypt/live/{{ name }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ name }}/privkey.pem; diff --git a/bouncer/Readme.md b/bouncer/Readme.md new file mode 100644 index 0000000..a27263c --- /dev/null +++ b/bouncer/Readme.md @@ -0,0 +1,48 @@ +# Automatic Swarm Nginx Loadbalancer + +## Environment variables +This container has its own environment variables, AS WELL AS scanning for some environment variables associated with your services. +These should not be confused. + +### Load balancer Configuration +#### Main configuration +| Key | Default | Options | Behaviour | +|----------------------------------------|---------|---------------------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| DOCKER_HOST | false | | Define a http endpoint representing your docker socket. If this is null, it connects to /var/lib/docker.sock | +| GLOBAL_CERT | false | Contents of an ssl certificate | If you want to provide a single cert for all endpoints, perhaps with a catch-all that may be later overriden, you can provide the whole contents of a certificates file here. | +| GLOBAL_CERT_KEY | false | Contents of an ssl certificates private key | The private key related to GLOBAL CERT. These must be provided in tandem. | +| BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS | false | positive numbers | To force the bouncer to update on a schedule even if no changes are detected, measured in seconds | + +#### For using with lets encrypt +| Key | Default | Options | Behaviour | +|---------------------------|-----------|---------------------------|--------------------------------------------------------------------------------------| +| BOUNCER_LETSENCRYPT_MODE | 'staging' | 'staging' or 'production' | Determine if this is going to connect to a production or staging Lets Encrypt server | +| BOUNCER_LETSENCRYPT_EMAIL | | 'bob@example.com' | Email address to associate with lets encrypt | + +#### For using S3 for generated cert synchronisation with Lets Encrypt +| Key | Default | Options | Behaviour | +|------------------------------------|---------|-----------------|---------------------------------------------------------------------------------------| +| BOUNCER_S3_BUCKET | false | | enable S3 behaviour to store lets-encrypt generated certs | +| BOUNCER_S3_ENDPOINT | false | | define s3 endpoint to override default AWS s3 implementation, for example, with minio | +| BOUNCER_S3_KEY_ID | false | | S3 API Key ID | | +| BOUNCER_s3_KEY_SECRET | false | | S3 API Key Secret | +| BOUNCER_S3_REGION | false | | S3 API Region | +| BOUNCER_S3_USE_PATH_STYLE_ENDPOINT | false | `true or false` | Needed for minio | +| BOUNCER_S3_PREFIX | false | | Prefix file path in s3 bucket | + +### Served Instance Configuration + +These environment variables need to be applied to the CONSUMING SERVICE and not the loadbalancer container itself. + +| Key | Example | Behaviour | +|--------------------------------|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------| +| BOUNCER_DOMAIN | "a.example.com" | The domain that should be directed to this container | +| BOUNCER_LETSENCRYPT | Values are "yes" or "true", anything else is false | To enable, or disable Lets Encrypt service for this hostname | +| BOUNCER_TARGET_PORT | 9000 | Explicitly define the port you want to hit the service on, in case of ambiguity | +| BOUNCER_ALLOW_NON_SSL | Defaults to enabled. Values are "yes" or "true", anything else is false | Should HTTP only traffic be allowed to hit this service? If disabled, http traffic is forwarded towards https | +| BOUNCER_ALLOW_WEBSOCKETS | Defaults to enabled. Values are "yes" or "true", anything else is false | Enable websocket behaviour | +| BOUNCER_ALLOW_LARGE_PAYLOADS | Defaults to disabled. | Allows overriding the default nginx payload size. Related to BOUNCER_MAX_PAYLOADS_MEGABYTES | +| BOUNCER_MAX_PAYLOADS_MEGABYTES | numbers | Size of max payload to allow, in megabytes. Requires BOUNCER_ALLOW_LARGE_PAYLOADS to be enabled | + +## Security considerations +If you're putting this behind access control to the docker socket, it will need access to the /swarm /services and /containers endpoints of the docker api. \ No newline at end of file diff --git a/bouncer/bouncer b/bouncer/bouncer index 97ad8ff..fbca4ed 100755 --- a/bouncer/bouncer +++ b/bouncer/bouncer @@ -21,16 +21,22 @@ class BouncerTarget { private string $id; private array $domains; - private string $ip; - private int $port = 80; + private string $endpointHostnameOrIp; + private ?int $port = null; private bool $letsEncrypt = false; private string $targetPath; private bool $allowNonSSL = true; private bool $useTemporaryCert = true; + private bool $useGlobalCert = false; private bool $allowWebsocketSupport = true; private bool $allowLargePayloads = false; private int $maxPayloadSizeMegabytes = 256; + public function __construct( + private Logger $logger + ) { + } + public function __toArray() { return [ @@ -40,6 +46,7 @@ class BouncerTarget 'letsEncrypt' => $this->isLetsEncrypt(), 'targetPath' => $this->getTargetPath(), 'useTemporaryCert' => $this->isUseTemporaryCert(), + 'useGlobalCert' => $this->isUseGlobalCert(), 'allowNonSSL' => $this->isAllowNonSSL(), 'allowWebsocketSupport' => $this->isAllowWebsocketSupport(), 'allowLargePayloads' => $this->isAllowLargePayloads(), @@ -59,6 +66,21 @@ class BouncerTarget return $this; } + public function isUseGlobalCert(): bool + { + return $this->useGlobalCert; + } + + public function setUseGlobalCert(bool $useGlobalCert): BouncerTarget + { + $this->useGlobalCert = $useGlobalCert; + + // Global cert overrides temporary certs. + $this->setUseTemporaryCert(false); + + return $this; + } + public function isAllowWebsocketSupport(): bool { return $this->allowWebsocketSupport; @@ -149,23 +171,28 @@ class BouncerTarget return $this; } - public function getIp(): string + public function getEndpointHostnameOrIp(): string { - return $this->ip; + return $this->endpointHostnameOrIp; } - public function setIp(string $ip): BouncerTarget + public function setEndpointHostnameOrIp(string $endpointHostnameOrIp): BouncerTarget { - $this->ip = $ip; + $this->endpointHostnameOrIp = $endpointHostnameOrIp; return $this; } - public function getPort(): int + public function getPort(): ?int { return $this->port; } + public function isPortSet(): bool + { + return $this->port !== null; + } + public function setPort(int $port): BouncerTarget { $this->port = $port; @@ -189,6 +216,28 @@ class BouncerTarget return $this; } + + public function isEndpointValid(): bool + { + // Is it just an IP? + if (filter_var($this->getEndpointHostnameOrIp(), FILTER_VALIDATE_IP)) { + // $this->logger->debug(sprintf('%s isEndpointValid: %s is a normal IP', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp())); + + return true; + } + + // Is it a Hostname that resolves? + $resolved = gethostbyname($this->getEndpointHostnameOrIp()); + if (filter_var($resolved, FILTER_VALIDATE_IP)) { + // $this->logger->debug(sprintf('%s isEndpointValid: %s is a hostname that resolves to a normal IP %s', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp(), $resolved)); + + return true; + } + + // $this->logger->debug(sprintf('%s isEndpointValid: %s is a hostname does not resolve', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp())); + + return false; + } } class Bouncer @@ -200,10 +249,14 @@ class Bouncer private Filesystem $configFilesystem; private Filesystem $certificateStoreLocal; private ?Filesystem $certificateStoreRemote = null; + private Filesystem $providedCertificateStore; private Logger $logger; private string $instanceStateHash = ''; private array $fileHashes; private bool $swarmMode = false; + private bool $useGlobalCert = false; + private int $forcedUpdateIntervalSeconds = 600; + private ?int $lastUpdateEpoch = null; public function __construct() { @@ -216,14 +269,13 @@ class Bouncer $stdout->setFormatter(new ColoredLineFormatter(null, "%level_name%: %message% \n")); $this->logger->pushHandler($stdout); - $this->client = new Guzzle( - [ - 'base_uri' => 'http://localhost', - 'curl' => [ - CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock', - ], - ] - ); + if (isset($this->environment['DOCKER_HOST'])) { + $this->logger->info(sprintf('%s Connecting to %s', Emoji::electricPlug(), $this->environment['DOCKER_HOST'])); + $this->client = new Guzzle(['base_uri' => $this->environment['DOCKER_HOST']]); + } else { + $this->logger->info(sprintf('%s Connecting to /var/run/docker.sock', Emoji::electricPlug())); + $this->client = new Guzzle(['base_uri' => 'http://localhost', 'curl' => [CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock']]); + } $this->loader = new FilesystemLoader([ __DIR__, @@ -236,6 +288,9 @@ class Bouncer // Set up Local certificate store $this->certificateStoreLocal = new Filesystem(new LocalFilesystemAdapter('/etc/letsencrypt')); + // Set up Local certificate store for certificates provided to us + $this->providedCertificateStore = new Filesystem(new LocalFilesystemAdapter('/certs')); + // Set up Remote certificate store, if configured if (isset($this->environment['BOUNCER_S3_BUCKET'])) { $this->certificateStoreRemote = new Filesystem( @@ -255,6 +310,20 @@ class Bouncer ) ); } + + // Allow defined global cert if set + if (isset($this->environment['GLOBAL_CERT'], $this->environment['GLOBAL_CERT_KEY'])) { + $this->setUseGlobalCert(true); + $this->providedCertificateStore->write('global.crt', str_replace('\\n', "\n", trim($this->environment['GLOBAL_CERT'], '"'))); + $this->providedCertificateStore->write('global.key', str_replace('\\n', "\n", trim($this->environment['GLOBAL_CERT_KEY'], '"'))); + $this->logger->info(sprintf("%s GLOBAL_CERT was set, so we're going to use a defined certificate!", Emoji::globeShowingEuropeAfrica())); + } + + // Determine forced update interval. + if (isset($this->environment['BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS']) && is_numeric($this->environment['BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS'])) { + $this->setForcedUpdateIntervalSeconds($this->environment['BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS']); + } + $this->logger->info(sprintf('%s Forced update interval is every %d seconds', Emoji::watch(), $this->getForcedUpdateIntervalSeconds())); } public function isSwarmMode(): bool @@ -269,6 +338,30 @@ class Bouncer return $this; } + public function isUseGlobalCert(): bool + { + return $this->useGlobalCert; + } + + public function setUseGlobalCert(bool $useGlobalCert): Bouncer + { + $this->useGlobalCert = $useGlobalCert; + + return $this; + } + + public function getForcedUpdateIntervalSeconds(): int + { + return $this->forcedUpdateIntervalSeconds; + } + + public function setForcedUpdateIntervalSeconds(int $forcedUpdateIntervalSeconds): Bouncer + { + $this->forcedUpdateIntervalSeconds = $forcedUpdateIntervalSeconds; + + return $this; + } + /** * @throws \GuzzleHttp\Exception\GuzzleException * @@ -288,29 +381,40 @@ class Bouncer [$envKey, $envVal] = explode('=', $environmentItem, 2); $envs[$envKey] = $envVal; } else { - $envs[$envKey] = true; + $envs[$environmentItem] = true; } } } if (isset($envs['BOUNCER_DOMAIN'])) { - $bouncerTarget = (new BouncerTarget()) + $bouncerTarget = (new BouncerTarget($this->logger)) ->setId($inspect['Id']) ; $bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget); if (isset($inspect['NetworkSettings']['IPAddress']) && !empty($inspect['NetworkSettings']['IPAddress'])) { // As per docker service - $bouncerTarget->setIp($inspect['NetworkSettings']['IPAddress']); + $bouncerTarget->setEndpointHostnameOrIp($inspect['NetworkSettings']['IPAddress']); } else { // As per docker compose $networks = array_values($inspect['NetworkSettings']['Networks']); - $bouncerTarget->setIp($networks[0]['IPAddress']); + $bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']); } - // $this->logger->debug(sprintf('Decided that %s has the ip %s', $bouncerTarget->getName(), $bouncerTarget->getIp())); - $bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getIp(), $bouncerTarget->getPort())); + $bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort() >= 0 ? $bouncerTarget->getPort() : 80)); - $bouncerTargets[] = $bouncerTarget; + $bouncerTarget->setUseGlobalCert($this->isUseGlobalCert()); + + $valid = $bouncerTarget->isEndpointValid(); + // $this->logger->debug(sprintf( + // '%s Decided that %s has the endpoint %s and it %s.', + // Emoji::magnifyingGlassTiltedLeft(), + // $bouncerTarget->getName(), + // $bouncerTarget->getEndpointHostnameOrIp(), + // $valid ? 'is valid' : 'is not valid' + // )); + if ($valid) { + $bouncerTargets[] = $bouncerTarget; + } } } @@ -340,19 +444,39 @@ class Bouncer $envs[$eKey] = $eVal; } if (isset($envs['BOUNCER_DOMAIN'])) { - $bouncerTarget = (new BouncerTarget()) + $bouncerTarget = (new BouncerTarget($this->logger)) ->setId($service['ID']) ; - $bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget); - $bouncerTarget->setIp('172.17.0.1'); - $bouncerTarget->setPort($service['Endpoint']['Ports'][0]['PublishedPort']); - $bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getIp(), $bouncerTarget->getPort())); + if ($bouncerTarget->isPortSet()) { + $bouncerTarget->setEndpointHostnameOrIp($service['Spec']['Name']); + // $this->logger->info(sprintf('Ports for %s has been explicitly set to %s:%d.', $bouncerTarget->getName(), $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort())); + } elseif (isset($service['Endpoint']['Ports'])) { + $bouncerTarget->setEndpointHostnameOrIp('172.17.0.1'); + $bouncerTarget->setPort($service['Endpoint']['Ports'][0]['PublishedPort']); + } else { + $this->logger->warning(sprintf('Ports block missing for %s.', $bouncerTarget->getName())); + + continue; + } + $bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort())); + + $bouncerTarget->setUseGlobalCert($this->isUseGlobalCert()); // $this->logger->debug(sprintf('Decided that %s has the target path %s', $bouncerTarget->getName(), $bouncerTarget->getTargetPath())); - $bouncerTargets[] = $bouncerTarget; + $valid = $bouncerTarget->isEndpointValid(); + // $this->logger->debug(sprintf( + // '%s Decided that %s has the endpoint %s and it %s.', + // Emoji::magnifyingGlassTiltedLeft(), + // $bouncerTarget->getName(), + // $bouncerTarget->getEndpointHostnameOrIp(), + // $valid ? 'is valid' : 'is not valid' + // )); + if ($valid) { + $bouncerTargets[] = $bouncerTarget; + } } } } @@ -431,6 +555,9 @@ class Bouncer */ private function stateHasChanged(): bool { + if ($this->lastUpdateEpoch === null || $this->lastUpdateEpoch <= time() - $this->forcedUpdateIntervalSeconds) { + return true; + } $newInstanceStates = []; // Standard Containers @@ -486,6 +613,8 @@ class Bouncer $this->logger->info(sprintf('%s Swarm mode is %s.', Emoji::CHARACTER_HONEYBEE, $this->isSwarmMode() ? 'enabled' : 'disabled')); $targets = $this->isSwarmMode() ? $this->findContainersSwarmMode() : $this->findContainersContainerMode(); + $this->wipeNginxConfig(); + $this->logger->info(sprintf('%s Found %d services with BOUNCER_DOMAIN set', Emoji::CHARACTER_MAGNIFYING_GLASS_TILTED_LEFT, count($targets))); foreach ($targets as $target) { $this->generateNginxConfig($target); @@ -502,6 +631,7 @@ class Bouncer while ($this->stateHasChanged() === false) { sleep(5); } + $this->lastUpdateEpoch = time(); $this->logger->info(sprintf('%s Host Container state has changed', Emoji::CHARACTER_WARNING)); } @@ -567,10 +697,8 @@ class Bouncer private function generateNginxConfig(BouncerTarget $target): self { - $this->configFilesystem->write( - $target->getName(), - $this->twig->render('NginxTemplate.twig', $target->__toArray()) - ); + $configData = $this->twig->render('NginxTemplate.twig', $target->__toArray()); + $this->configFilesystem->write($target->getName(), $configData); $this->logger->info(sprintf('%s Created Nginx config for http://%s', Emoji::CHARACTER_PENCIL, $target->getName())); return $this; @@ -644,6 +772,19 @@ class Bouncer $this->logger->info(sprintf('%s Restarting nginx', Emoji::CHARACTER_TIMER_CLOCK)); $shell->run($command); } + + private function wipeNginxConfig(): void + { + $this->logger->debug('Purging existing config files ...'); + foreach ($this->configFilesystem->listContents('') as $file) { + /** @var FileAttributes $file */ + if ($file->isFile() && $file->path() != 'default' && $file->path() != 'default-ssl') { + $this->configFilesystem->delete($file->path()); + // $this->logger->debug(sprintf(' > %s', $file->path())); + } + } + // $this->logger->debug('Purge complete!'); + } } (new Bouncer())->run(); diff --git a/bouncer/composer.json b/bouncer/composer.json index 57fbf34..18423ff 100644 --- a/bouncer/composer.json +++ b/bouncer/composer.json @@ -1,13 +1,13 @@ { "name": "benzine/bouncer", - "description": "Nginx Configuration Management", + "description": "Automated Docker-swarm aware Nginx configuration management", "type": "project", "config": { "sort-packages": true }, "license": "GPL-3.0-or-later", "require": { - "php": "^8.0", + "php": "^8.1", "ext-json": "*", "ext-curl": "*", "kint-php/kint": "^3.3", diff --git a/bouncer/composer.lock b/bouncer/composer.lock index db77a09..1d08fd8 100644 --- a/bouncer/composer.lock +++ b/bouncer/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "bb6894396a4d90d304b95586f326ea71", + "content-hash": "afde4d97f684020724ec24b67577d3ae", "packages": [ { "name": "adambrett/shell-wrapper", @@ -108,16 +108,16 @@ }, { "name": "aws/aws-sdk-php", - "version": "3.224.0", + "version": "3.228.3", "source": { "type": "git", "url": "https://github.com/aws/aws-sdk-php.git", - "reference": "bc5eb18414ef703c5f39a5a009a437c74c228306" + "reference": "4dad57c95c7ff1dfcea29a7877ce64720b3318c3" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/bc5eb18414ef703c5f39a5a009a437c74c228306", - "reference": "bc5eb18414ef703c5f39a5a009a437c74c228306", + "url": "https://api.github.com/repos/aws/aws-sdk-php/zipball/4dad57c95c7ff1dfcea29a7877ce64720b3318c3", + "reference": "4dad57c95c7ff1dfcea29a7877ce64720b3318c3", "shasum": "" }, "require": { @@ -125,9 +125,9 @@ "ext-json": "*", "ext-pcre": "*", "ext-simplexml": "*", - "guzzlehttp/guzzle": "^5.3.3 || ^6.2.1 || ^7.0", + "guzzlehttp/guzzle": "^6.5.8 || ^7.4.5", "guzzlehttp/promises": "^1.4.0", - "guzzlehttp/psr7": "^1.7.0 || ^2.1.1", + "guzzlehttp/psr7": "^1.8.5 || ^2.3", "mtdowling/jmespath.php": "^2.6", "php": ">=5.5" }, @@ -193,9 +193,9 @@ "support": { "forum": "https://forums.aws.amazon.com/forum.jspa?forumID=80", "issues": "https://github.com/aws/aws-sdk-php/issues", - "source": "https://github.com/aws/aws-sdk-php/tree/3.224.0" + "source": "https://github.com/aws/aws-sdk-php/tree/3.228.3" }, - "time": "2022-05-27T20:23:28+00:00" + "time": "2022-06-24T20:24:09+00:00" }, { "name": "bramus/ansi-php", @@ -883,16 +883,16 @@ }, { "name": "monolog/monolog", - "version": "2.6.0", + "version": "2.7.0", "source": { "type": "git", "url": "https://github.com/Seldaek/monolog.git", - "reference": "247918972acd74356b0a91dfaa5adcaec069b6c0" + "reference": "5579edf28aee1190a798bfa5be8bc16c563bd524" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/Seldaek/monolog/zipball/247918972acd74356b0a91dfaa5adcaec069b6c0", - "reference": "247918972acd74356b0a91dfaa5adcaec069b6c0", + "url": "https://api.github.com/repos/Seldaek/monolog/zipball/5579edf28aee1190a798bfa5be8bc16c563bd524", + "reference": "5579edf28aee1190a798bfa5be8bc16c563bd524", "shasum": "" }, "require": { @@ -971,7 +971,7 @@ ], "support": { "issues": "https://github.com/Seldaek/monolog/issues", - "source": "https://github.com/Seldaek/monolog/tree/2.6.0" + "source": "https://github.com/Seldaek/monolog/tree/2.7.0" }, "funding": [ { @@ -983,7 +983,7 @@ "type": "tidelift" } ], - "time": "2022-05-10T09:36:00+00:00" + "time": "2022-06-09T08:59:12+00:00" }, { "name": "mtdowling/jmespath.php", @@ -1435,16 +1435,16 @@ }, { "name": "symfony/polyfill-ctype", - "version": "v1.25.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-ctype.git", - "reference": "30885182c981ab175d4d034db0f6f469898070ab" + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/30885182c981ab175d4d034db0f6f469898070ab", - "reference": "30885182c981ab175d4d034db0f6f469898070ab", + "url": "https://api.github.com/repos/symfony/polyfill-ctype/zipball/6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", + "reference": "6fd1b9a79f6e3cf65f9e679b23af304cd9e010d4", "shasum": "" }, "require": { @@ -1459,7 +1459,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -1497,7 +1497,7 @@ "portable" ], "support": { - "source": "https://github.com/symfony/polyfill-ctype/tree/v1.25.0" + "source": "https://github.com/symfony/polyfill-ctype/tree/v1.26.0" }, "funding": [ { @@ -1513,20 +1513,20 @@ "type": "tidelift" } ], - "time": "2021-10-20T20:35:02+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-mbstring", - "version": "v1.25.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-mbstring.git", - "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825" + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/0abb51d2f102e00a4eefcf46ba7fec406d245825", - "reference": "0abb51d2f102e00a4eefcf46ba7fec406d245825", + "url": "https://api.github.com/repos/symfony/polyfill-mbstring/zipball/9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", + "reference": "9344f9cb97f3b19424af1a21a3b0e75b0a7d8d7e", "shasum": "" }, "require": { @@ -1541,7 +1541,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -1580,7 +1580,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.25.0" + "source": "https://github.com/symfony/polyfill-mbstring/tree/v1.26.0" }, "funding": [ { @@ -1596,7 +1596,7 @@ "type": "tidelift" } ], - "time": "2021-11-30T18:21:41+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "twig/twig", @@ -2337,16 +2337,16 @@ }, { "name": "symfony/console", - "version": "v6.1.0", + "version": "v6.1.2", "source": { "type": "git", "url": "https://github.com/symfony/console.git", - "reference": "c9646197ef43b0e2ff44af61e7f0571526fd4170" + "reference": "7a86c1c42fbcb69b59768504c7bca1d3767760b7" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/console/zipball/c9646197ef43b0e2ff44af61e7f0571526fd4170", - "reference": "c9646197ef43b0e2ff44af61e7f0571526fd4170", + "url": "https://api.github.com/repos/symfony/console/zipball/7a86c1c42fbcb69b59768504c7bca1d3767760b7", + "reference": "7a86c1c42fbcb69b59768504c7bca1d3767760b7", "shasum": "" }, "require": { @@ -2413,7 +2413,7 @@ "terminal" ], "support": { - "source": "https://github.com/symfony/console/tree/v6.1.0" + "source": "https://github.com/symfony/console/tree/v6.1.2" }, "funding": [ { @@ -2429,7 +2429,7 @@ "type": "tidelift" } ], - "time": "2022-05-27T06:34:22+00:00" + "time": "2022-06-26T13:01:30+00:00" }, { "name": "symfony/event-dispatcher", @@ -2789,16 +2789,16 @@ }, { "name": "symfony/polyfill-intl-grapheme", - "version": "v1.25.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-grapheme.git", - "reference": "81b86b50cf841a64252b439e738e97f4a34e2783" + "reference": "433d05519ce6990bf3530fba6957499d327395c2" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/81b86b50cf841a64252b439e738e97f4a34e2783", - "reference": "81b86b50cf841a64252b439e738e97f4a34e2783", + "url": "https://api.github.com/repos/symfony/polyfill-intl-grapheme/zipball/433d05519ce6990bf3530fba6957499d327395c2", + "reference": "433d05519ce6990bf3530fba6957499d327395c2", "shasum": "" }, "require": { @@ -2810,7 +2810,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2850,7 +2850,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.25.0" + "source": "https://github.com/symfony/polyfill-intl-grapheme/tree/v1.26.0" }, "funding": [ { @@ -2866,20 +2866,20 @@ "type": "tidelift" } ], - "time": "2021-11-23T21:10:46+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-intl-normalizer", - "version": "v1.25.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-intl-normalizer.git", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8" + "reference": "219aa369ceff116e673852dce47c3a41794c14bd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/8590a5f561694770bdcd3f9b5c69dde6945028e8", - "reference": "8590a5f561694770bdcd3f9b5c69dde6945028e8", + "url": "https://api.github.com/repos/symfony/polyfill-intl-normalizer/zipball/219aa369ceff116e673852dce47c3a41794c14bd", + "reference": "219aa369ceff116e673852dce47c3a41794c14bd", "shasum": "" }, "require": { @@ -2891,7 +2891,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -2934,7 +2934,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.25.0" + "source": "https://github.com/symfony/polyfill-intl-normalizer/tree/v1.26.0" }, "funding": [ { @@ -2950,20 +2950,20 @@ "type": "tidelift" } ], - "time": "2021-02-19T12:13:01+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/polyfill-php80", - "version": "v1.25.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php80.git", - "reference": "4407588e0d3f1f52efb65fbe92babe41f37fe50c" + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/4407588e0d3f1f52efb65fbe92babe41f37fe50c", - "reference": "4407588e0d3f1f52efb65fbe92babe41f37fe50c", + "url": "https://api.github.com/repos/symfony/polyfill-php80/zipball/cfa0ae98841b9e461207c13ab093d76b0fa7bace", + "reference": "cfa0ae98841b9e461207c13ab093d76b0fa7bace", "shasum": "" }, "require": { @@ -2972,7 +2972,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -3017,7 +3017,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php80/tree/v1.25.0" + "source": "https://github.com/symfony/polyfill-php80/tree/v1.26.0" }, "funding": [ { @@ -3033,20 +3033,20 @@ "type": "tidelift" } ], - "time": "2022-03-04T08:16:47+00:00" + "time": "2022-05-10T07:21:04+00:00" }, { "name": "symfony/polyfill-php81", - "version": "v1.25.0", + "version": "v1.26.0", "source": { "type": "git", "url": "https://github.com/symfony/polyfill-php81.git", - "reference": "5de4ba2d41b15f9bd0e19b2ab9674135813ec98f" + "reference": "13f6d1271c663dc5ae9fb843a8f16521db7687a1" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/5de4ba2d41b15f9bd0e19b2ab9674135813ec98f", - "reference": "5de4ba2d41b15f9bd0e19b2ab9674135813ec98f", + "url": "https://api.github.com/repos/symfony/polyfill-php81/zipball/13f6d1271c663dc5ae9fb843a8f16521db7687a1", + "reference": "13f6d1271c663dc5ae9fb843a8f16521db7687a1", "shasum": "" }, "require": { @@ -3055,7 +3055,7 @@ "type": "library", "extra": { "branch-alias": { - "dev-main": "1.23-dev" + "dev-main": "1.26-dev" }, "thanks": { "name": "symfony/polyfill", @@ -3096,7 +3096,7 @@ "shim" ], "support": { - "source": "https://github.com/symfony/polyfill-php81/tree/v1.25.0" + "source": "https://github.com/symfony/polyfill-php81/tree/v1.26.0" }, "funding": [ { @@ -3112,7 +3112,7 @@ "type": "tidelift" } ], - "time": "2021-09-13T13:58:11+00:00" + "time": "2022-05-24T11:49:31+00:00" }, { "name": "symfony/process", @@ -3324,16 +3324,16 @@ }, { "name": "symfony/string", - "version": "v6.1.0", + "version": "v6.1.2", "source": { "type": "git", "url": "https://github.com/symfony/string.git", - "reference": "d3edc75baf9f1d4f94879764dda2e1ac33499529" + "reference": "1903f2879875280c5af944625e8246d81c2f0604" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/symfony/string/zipball/d3edc75baf9f1d4f94879764dda2e1ac33499529", - "reference": "d3edc75baf9f1d4f94879764dda2e1ac33499529", + "url": "https://api.github.com/repos/symfony/string/zipball/1903f2879875280c5af944625e8246d81c2f0604", + "reference": "1903f2879875280c5af944625e8246d81c2f0604", "shasum": "" }, "require": { @@ -3389,7 +3389,7 @@ "utf8" ], "support": { - "source": "https://github.com/symfony/string/tree/v6.1.0" + "source": "https://github.com/symfony/string/tree/v6.1.2" }, "funding": [ { @@ -3405,7 +3405,7 @@ "type": "tidelift" } ], - "time": "2022-04-22T08:18:23+00:00" + "time": "2022-06-26T16:35:04+00:00" } ], "aliases": [],