From a406e895d23c06031f73d576d224ba1f818a2b86 Mon Sep 17 00:00:00 2001 From: Matthew Baggett Date: Thu, 8 Feb 2024 16:48:07 +0100 Subject: [PATCH] Custom certificate support. Wildcard domain support --- .github/workflows/bouncer.yml | 15 +- bouncer/Dockerfile | 42 ++-- bouncer/Makefile | 10 +- bouncer/Readme.md | 34 ++-- bouncer/bouncer.runit | 1 + bouncer/docker-compose.yml | 10 +- bouncer/logs.runit | 6 +- bouncer/src/Bouncer.php | 278 ++++++++++++++++----------- bouncer/src/EnumCertType.php | 18 ++ bouncer/src/Settings/Settings.php | 20 +- bouncer/src/Target.php | 136 ++++++++++--- bouncer/templates/NginxTemplate.twig | 25 +-- 12 files changed, 393 insertions(+), 202 deletions(-) create mode 100644 bouncer/src/EnumCertType.php diff --git a/.github/workflows/bouncer.yml b/.github/workflows/bouncer.yml index 2e6759e..3e8e52d 100644 --- a/.github/workflows/bouncer.yml +++ b/.github/workflows/bouncer.yml @@ -22,6 +22,12 @@ jobs: name: Bake Bouncer Container runs-on: ubuntu-latest steps: + - name: "Setup: Checkout Source" + uses: actions/checkout@v4 + with: + sparse-checkout: | + bouncer + - name: "Setup: Get Date" id: date run: | @@ -35,7 +41,7 @@ jobs: - name: "Setup: PHP" uses: shivammathur/setup-php@v2 with: - php-version: 8.1 + php-version: 8.2 env: runner: self-hosted @@ -61,12 +67,6 @@ jobs: username: matthewbaggett password: ${{ secrets.GHCR_PASSWORD }} - - name: "Setup: Checkout Source" - uses: actions/checkout@v4 - with: - sparse-checkout: | - bouncer - - name: "Setup: Configure Cache" uses: actions/cache@v4 with: @@ -90,6 +90,7 @@ jobs: push: true build-args: | GIT_SHA=${{ github.sha }} + GIT_BUILD_ID=${{ github.ref_name }} BUILD_DATE=${{ steps.date.outputs.container_build_datetime }} GIT_COMMIT_MESSAGE=${{ github.event.head_commit.message }} tags: | diff --git a/bouncer/Dockerfile b/bouncer/Dockerfile index 472c0cd..023bf06 100644 --- a/bouncer/Dockerfile +++ b/bouncer/Dockerfile @@ -1,9 +1,11 @@ +# checkov:skip=CKV_DOCKER_3 user cannot be determined at this stage. FROM php:cli as bouncer LABEL maintainer="Matthew Baggett " \ org.label-schema.vcs-url="https://github.com/benzine-framework/docker" \ org.opencontainers.image.source="https://github.com/benzine-framework/docker" -USER root +SHELL ["/bin/bash", "-o", "pipefail", "-c"] + # ts:skip=AC_DOCKER_0002 Mis-detecting usage of apt instead of apt-get # Install nginx, certbot RUN apt-get -qq update && \ @@ -39,12 +41,12 @@ COPY self-signed-certificates /certs # Install runits for services COPY nginx.runit /etc/service/nginx/run -COPY logs.runit /etc/service/nginx-logs/run -COPY logs.finish /etc/service/nginx-logs/finish +#COPY logs.runit /etc/service/nginx-logs/run +#COPY logs.finish /etc/service/nginx-logs/finish COPY bouncer.runit /etc/service/bouncer/run COPY bouncer.finish /etc/service/bouncer/finish -COPY logs-nginx-access.runit /etc/service/logs-nginx-access/run -COPY logs-nginx-error.runit /etc/service/logs-nginx-error/run +#COPY logs-nginx-access.runit /etc/service/logs-nginx-access/run +#COPY logs-nginx-error.runit /etc/service/logs-nginx-error/run RUN chmod +x /etc/service/*/run /etc/service/*/finish # Copy default nginx bits @@ -69,6 +71,16 @@ COPY src /app/src COPY templates /app/templates RUN chmod +x /app/bin/bouncer +# stuff some envs from build +ARG BUILD_DATE +ARG GIT_SHA +ARG GIT_BUILD_ID +ARG GIT_COMMIT_MESSAGE +ENV BUILD_DATE=${BUILD_DATE} \ + GIT_SHA=${GIT_SHA} \ + GIT_BUILD_ID=${GIT_BUILD_ID} \ + GIT_COMMIT_MESSAGE=${GIT_COMMIT_MESSAGE} + # Create some volumes for logs and certs VOLUME /etc/letsencrypt VOLUME /var/log/bouncer @@ -77,32 +89,26 @@ VOLUME /var/log/bouncer EXPOSE 80 EXPOSE 443 -# Down-privelege to bouncer -USER bouncer - # Set a healthcheck to curl the bouncer and expect a 200 HEALTHCHECK --start-period=30s \ CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 -# stuff some envs from build -ARG BUILD_DATE -ARG GIT_SHA -ARG GIT_COMMIT_MESSAGE -ENV BUILD_DATE=${BUILD_DATE} \ - GIT_SHA=${GIT_SHA} \ - GIT_COMMIT_MESSAGE=${GIT_COMMIT_MESSAGE} +RUN printenv | sort -FROM benzine/php:nginx-8.1 as test-app-a +# checkov:skip=CKV_DOCKER_3 user cannot be determined at this stage. +FROM php:nginx as test-app-a COPY ./test/public-web-a /app/public HEALTHCHECK --start-period=30s \ CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 -FROM benzine/php:nginx-8.1 as test-app-b +# checkov:skip=CKV_DOCKER_3 user cannot be determined at this stage. +FROM php:nginx as test-app-b COPY ./test/public-web-b /app/public HEALTHCHECK --start-period=30s \ CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 -FROM benzine/php:nginx-8.1 as test-app-c +# checkov:skip=CKV_DOCKER_3 user cannot be determined at this stage. +FROM php:nginx as test-app-c COPY ./test/public-web-c /app/public HEALTHCHECK --start-period=30s \ CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 diff --git a/bouncer/Makefile b/bouncer/Makefile index 643a863..20cc973 100644 --- a/bouncer/Makefile +++ b/bouncer/Makefile @@ -4,17 +4,21 @@ php-cs-fixer: fix: php-cs-fixer +image_reference_devel:=ghcr.io/benzine-framework/bouncer:devel + build-n-push: fix docker build \ --build-arg BUILD_DATE="$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --build-arg GIT_SHA="$(shell git rev-parse HEAD)" \ + --build-arg GIT_BUILD_ID="$(shell git rev-parse --abbrev-ref HEAD)-$(shell git describe --tags --dirty --always | sed -e 's/^v//')" \ --build-arg GIT_COMMIT_MESSAGE="$(shell git log -1 --pretty=%B | head -n1)" \ + --build-context php:cli=docker-image://ghcr.io/benzine-framework/php:cli-8.2 \ --tag benzine/bouncer \ - --tag ghcr.io/benzine-framework/bouncer \ + --tag $(image_reference_devel) \ --target bouncer \ + --progress plain \ . - #docker push benzine/bouncer - docker push ghcr.io/benzine-framework/bouncer + docker push $(image_reference_devel) test-as-service: clean docker build -t bouncer --target bouncer . diff --git a/bouncer/Readme.md b/bouncer/Readme.md index 753328b..f72c88c 100644 --- a/bouncer/Readme.md +++ b/bouncer/Readme.md @@ -9,19 +9,20 @@ These should not be confused. #### 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 | -| BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES | 15 | positive numbers | To limit the number lines of output regarding which domains have been configured. Any more domains than this count, and none will be output, instead replaced by "More than 15 Nginx configs generated.. Too many to show them all!" | -| LOG_NAME | bouncer | | The name of the log file to write to | -| LOG_FILE | /var/log/bouncer/bouncer.log | | The path to the log file to write to | -| LOG_LEVEL | debug | info, debug, critical etc | The level of logging to write to the log file. See Monolog docs. | -| LOG_LEVEL_NAME_LENGTH | 4 | positive numbers | The length of the level name to be written to the log file. See Monolog docs. | -| LOG_LINE_FORMAT | [%datetime%] %level_name%: %channel%: %message% | | The format of the log line. See Monolog docs. | -| LOG_COLOUR | true | true, false | Whether to colourise the log output sent to stdout. | | +| 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 | 0 | positive numbers | To force the bouncer to update on a schedule even if no changes are detected, measured in seconds | +| BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES | 15 | positive numbers | To limit the number lines of output regarding which domains have been configured. Any more domains than this count, and none will be output, instead replaced by "More than 15 Nginx configs generated.. Too many to show them all!" | +| BOUNCER_ALLOW_NON_SSL | Defaults to enabled. | Values are "yes" or "true", anything else is false | By default, should HTTP only traffic be allowed to hit this service? If disabled, http traffic is forwarded towards https. This can be over-ridden per-service with the same env. | +| LOG_NAME | bouncer | | The name of the log file to write to | +| LOG_FILE | /var/log/bouncer/bouncer.log | | The path to the log file to write to | +| LOG_LEVEL | debug | info, debug, critical etc | The level of logging to write to the log file. See Monolog docs. | +| LOG_LEVEL_NAME_LENGTH | 4 | positive numbers | The length of the level name to be written to the log file. See Monolog docs. | +| LOG_LINE_FORMAT | [%datetime%] %level_name%: %channel%: %message% | | The format of the log line. See Monolog docs. | +| LOG_COLOUR | true | true, false | Whether to colourise the log output sent to stdout. | #### For using with Lets Encrypt:tm: @@ -33,10 +34,10 @@ These should not be confused. #### For using S3-compatable storage 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_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 | @@ -50,9 +51,12 @@ These environment variables need to be applied to the CONSUMING SERVICE and not | ------------------------------ | ----------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | BOUNCER_DOMAIN | "a.example.com" | The domain that should be directed to this container | | BOUNCER_LABEL | "MyService" | The label that should be directed to this container | +| BOUNCER_IGNORE | not set | If set, the bouncer will ignore this service. Useful for services that are not ready to be exposed yet, or inherit BOUNCER_DOMAIN but need to be quashed. | | BOUNCER_AUTH | "username:password" e.g "root:toor" | Add a HTTP BASIC auth requirement to this hostname. | | BOUNCER_HOST_OVERRIDE | "localhost:80" | Override the host header that is sent to the service. Useful for services that are not aware of their own hostname, or annoying things like [mitmproxy](https://github.com/mitmproxy/mitmproxy/issues/3234) | | BOUNCER_LETSENCRYPT | Values are "yes" or "true", anything else is false | To enable, or disable Lets Encrypt service for this hostname | +| BOUNCER_CERT | "-----BEGIN CERTIFICATE-----\nMIIFfTCCBGWgAwIBAgIQCgFBQgAAABAhQ..." | The contents of a custom certificate to use for this hostname. | +| BOUNCER_CERT_KEY | "-----BEGIN PRIVATE KEY-----\nMIIEvgIBADANBgkqhkiG9w0BAQEFAASCB..." | The contents of a custom private key to use 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 | diff --git a/bouncer/bouncer.runit b/bouncer/bouncer.runit index cce0529..9db4d6b 100755 --- a/bouncer/bouncer.runit +++ b/bouncer/bouncer.runit @@ -1,4 +1,5 @@ #!/usr/bin/env bash sleep 3 +printf "\n\n\n\n" echo "Starting Bouncer" /app/bin/bouncer diff --git a/bouncer/docker-compose.yml b/bouncer/docker-compose.yml index 7db8660..52d29c6 100644 --- a/bouncer/docker-compose.yml +++ b/bouncer/docker-compose.yml @@ -1,11 +1,13 @@ -version: "3.4" +version: "3.5" services: bouncer: - image: bouncer + image: ghcr.io/benzine-framework/bouncer:devel build: context: . target: bouncer + additional_contexts: + - php:cli=docker-image://ghcr.io/benzine-framework/php:cli-8.2 volumes: - /var/run/docker.sock:/var/run/docker.sock - ./:/app @@ -26,6 +28,8 @@ services: build: context: . target: test-app-a + additional_contexts: + - php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2 volumes: - ./test/public-web-a:/app/public environment: @@ -38,6 +42,8 @@ services: build: context: . target: test-app-b + additional_contexts: + - php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2 volumes: - ./test/public-web-b:/app/public environment: diff --git a/bouncer/logs.runit b/bouncer/logs.runit index 31a909d..c4eac31 100644 --- a/bouncer/logs.runit +++ b/bouncer/logs.runit @@ -1,4 +1,4 @@ #!/usr/bin/env bash -#if [[ -f /var/log/bouncer/bouncer.log ]]; then -# tail -f /var/log/bouncer/bouncer.log -#fi +if [[ -f /var/log/bouncer/bouncer.log ]]; then + tail -f /var/log/bouncer/bouncer.log +fi diff --git a/bouncer/src/Bouncer.php b/bouncer/src/Bouncer.php index bea5aaa..0eb4228 100644 --- a/bouncer/src/Bouncer.php +++ b/bouncer/src/Bouncer.php @@ -7,7 +7,6 @@ namespace Bouncer; use AdamBrett\ShellWrapper\Command\Builder as CommandBuilder; use AdamBrett\ShellWrapper\Runners\Exec; use Aws\S3\S3Client; -use Carbon\Carbon; use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ServerException; @@ -21,6 +20,9 @@ use Bouncer\Logger\Formatter; use Spatie\Emoji\Emoji; use Symfony\Component\Yaml\Yaml; use Twig\Environment as Twig; +use Twig\Error\LoaderError; +use Twig\Error\RuntimeError; +use Twig\Error\SyntaxError; use Twig\Loader\FilesystemLoader as TwigLoader; use GuzzleHttp\Exception\GuzzleException; use Monolog\Processor; @@ -107,32 +109,6 @@ 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("GLOBAL_CERT was set, so we're going to use a defined certificate!", ['emoji' => 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']); - } - if ($this->getForcedUpdateIntervalSeconds() > 0) { - $this->logger->warning(' Forced update interval is every {interval_seconds} seconds', ['emoji' => Emoji::watch(), 'interval_seconds' => $this->getForcedUpdateIntervalSeconds()]); - } else { - $this->logger->info(' Forced update interval is disabled', ['emoji' => Emoji::watch()]); - } - - // Determine maximum notices for nginx config creation. - if (isset($this->environment['BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES']) && is_numeric($this->environment['BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES'])) { - $maxConfigCreationNotices = intval($this->environment['BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES']); - $originalMaximumNginxConfigCreationNotices = $this->getMaximumNginxConfigCreationNotices(); - $this->setMaximumNginxConfigCreationNotices($maxConfigCreationNotices); - $this->logger->warning(' Maximum Nginx config creation notices has been over-ridden: {original} => {new}', ['emoji' => Emoji::upsideDownFace(), 'original' => $originalMaximumNginxConfigCreationNotices, 'new' => $this->getMaximumNginxConfigCreationNotices()]); - } } public function getMaximumNginxConfigCreationNotices(): int @@ -194,30 +170,45 @@ class Bouncer $containers = json_decode($this->docker->request('GET', 'containers/json')->getBody()->getContents(), true); foreach ($containers as $container) { - $envs = []; - $inspect = json_decode($this->docker->request('GET', "containers/{$container['Id']}/json")->getBody()->getContents(), true); - if (isset($inspect['Config']['Env'])) { - foreach ($inspect['Config']['Env'] as $environmentItem) { - if (stripos($environmentItem, '=') !== false) { - [$envKey, $envVal] = explode('=', $environmentItem, 2); - $envs[$envKey] = $envVal; - } else { - $envs[$environmentItem] = true; - } + $envs = []; + $container = json_decode($this->docker->request('GET', "containers/{$container['Id']}/json")->getBody()->getContents(), true); + if ( + !isset($container['Config']['Env']) + ) { + continue; + } + // Parse all the environment variables and store them in an array. + foreach ($container['Config']['Env'] as $env) { + [$envKey, $envVal] = explode('=', $env, 2); + if (str_starts_with($envKey, 'BOUNCER_')) { + $envs[$envKey] = $envVal; } } + ksort($envs); + // If there are no BOUNCER_* environment variables, skip this service. + if (count($envs) == 0) { + continue; + } + // If BOUNCER_IGNORE is set, skip this service. + if (isset($envs['BOUNCER_IGNORE'])) { + continue; + } + if (isset($envs['BOUNCER_DOMAIN'])) { - $bouncerTarget = (new Target($this->logger)) - ->setId($inspect['Id']) + $bouncerTarget = (new Target( + logger: $this->logger, + settings: $this->settings, + )) + ->setId($container['Id']) ; $bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget); - if (isset($inspect['NetworkSettings']['IPAddress']) && !empty($inspect['NetworkSettings']['IPAddress'])) { + if (!empty($container['NetworkSettings']['IPAddress'])) { // As per docker service - $bouncerTarget->setEndpointHostnameOrIp($inspect['NetworkSettings']['IPAddress']); + $bouncerTarget->setEndpointHostnameOrIp($container['NetworkSettings']['IPAddress']); } else { // As per docker compose - $networks = array_values($inspect['NetworkSettings']['Networks']); + $networks = array_values($container['NetworkSettings']['Networks']); $bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']); } @@ -248,7 +239,7 @@ class Bouncer $services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true); if (isset($services['message'])) { - $this->logger->debug('Something happened while interrogating services.. This node is not a swarm node, cannot have services: {message}', ['emoji' => Emoji::warning(), 'message' => $services['message']]); + $this->logger->debug('Something happened while interrogating services.. This node is not a swarm node, cannot have services: {message}', ['emoji' => Emoji::warning() . ' ', 'message' => $services['message']]); } else { foreach ($services as $service) { $envs = []; @@ -260,24 +251,48 @@ class Bouncer ) { continue; } + // Parse all the environment variables and store them in an array. foreach ($service['Spec']['TaskTemplate']['ContainerSpec']['Env'] as $env) { - [$eKey, $eVal] = explode('=', $env, 2); - $envs[$eKey] = $eVal; + [$envKey, $envVal] = explode('=', $env, 2); + if (str_starts_with($envKey, 'BOUNCER_')) { + $envs[$envKey] = $envVal; + } + } + ksort($envs); + // If there are no BOUNCER_* environment variables, skip this service. + if (count($envs) == 0) { + continue; + } + // if BOUNCER_IGNORE is set, skip this service. + if (isset($envs['BOUNCER_IGNORE'])) { + continue; + } + + $bouncerTarget = (new Target( + logger: $this->logger, + settings: $this->settings, + )); + if (isset($envs['BOUNCER_LABEL'])) { + $bouncerTarget->setLabel($envs['BOUNCER_LABEL']); } if (isset($envs['BOUNCER_DOMAIN'])) { - $bouncerTarget = (new Target($this->logger)) - ->setId($service['ID']) - ; + $bouncerTarget->setId($service['ID']); + $bouncerTarget->setLabel($service['Spec']['Name']); $bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget); if ($bouncerTarget->isPortSet()) { $bouncerTarget->setEndpointHostnameOrIp($service['Spec']['Name']); - // $this->logger->info('Ports for {target_name} has been explicitly set to {host}:{port}.', ['emoji' => Emoji::warning(), 'target_name' => $bouncerTarget->getName(), 'host' => $bouncerTarget->getEndpointHostnameOrIp(), 'port' => $bouncerTarget->getPort()]); + // $this->logger->info('{label}: Ports for {target_name} has been explicitly set to {host}:{port}.', ['emoji' => Emoji::warning().' ', 'target_name' => $bouncerTarget->getName(), 'host' => $bouncerTarget->getEndpointHostnameOrIp(), 'port' => $bouncerTarget->getPort()]); } elseif (isset($service['Endpoint']['Ports'])) { $bouncerTarget->setEndpointHostnameOrIp('172.17.0.1'); $bouncerTarget->setPort(intval($service['Endpoint']['Ports'][0]['PublishedPort'])); } else { - $this->logger->warning('Ports block missing for {target_name}.', ['emoji' => Emoji::warning(), 'target_name' => $bouncerTarget->getName()]); + $this->logger->warning('{label}: ports block missing for {target_name}. Try setting BOUNCER_TARGET_PORT.', ['emoji' => Emoji::warning() . ' ', 'label' => $bouncerTarget->getLabel(), 'target_name' => $bouncerTarget->getName()]); + \Kint::dump( + $bouncerTarget->getId(), + $bouncerTarget->getLabel(), + $envs + ); continue; } @@ -306,18 +321,42 @@ class Bouncer public function run(): void { - $gitHash = substr($this->environment['GIT_SHA'], 0, 7); - $buildDate = Carbon::parse($this->environment['BUILD_DATE']); - $gitMessage = trim($this->environment['GIT_COMMIT_MESSAGE']); - $this->logger->info(' Starting Bouncer. Built on {build_date}, {build_ago}', ['emoji' => Emoji::redHeart(), 'build_date' => $buildDate->toDateTimeString(), 'build_ago' => $buildDate->ago()]); - $this->logger->info(' Build #{git_sha}: "{git_message}"', ['emoji' => Emoji::memo(), 'git_sha' => $gitHash, 'git_message' => $gitMessage]); + $this->logger->info('Starting Bouncer. Built {build_id} on {build_date}, {build_ago}', ['emoji' => Emoji::redHeart() . ' ', 'build_id' => $this->settings->get('build/id'), 'build_date' => $this->settings->get('build/date')->toDateTimeString(), 'build_ago' => $this->settings->get('build/date')->ago()]); + $this->logger->info('Build #{git_sha}: "{build_message}"', ['emoji' => Emoji::memo(), 'git_sha' => $this->settings->get('build/sha_short'), 'build_message' => $this->settings->get('build/message')]); + $this->logger->debug(' > HTTPS Listener is on {https_port}', ['emoji' => Emoji::ship(), 'https_port' => $this->settings->get('bouncer/https_port')]); + $this->logger->debug(' > HTTP Listener is on {http_port}', ['emoji' => Emoji::ship(), 'http_port' => $this->settings->get('bouncer/http_port')]); + + // Allow defined global cert if set + if ($this->settings->has('ssl/global_cert') && $this->settings->has('ssl/global_cert_key')) { + $this->setUseGlobalCert(true); + $this->providedCertificateStore->write('global.crt', str_replace('\\n', "\n", trim($this->settings->get('ssl/global_cert'), '"'))); + $this->providedCertificateStore->write('global.key', str_replace('\\n', "\n", trim($this->settings->get('ssl/global_cert_key'), '"'))); + } + $this->logger->debug(' > Global Cert is {enabled}', ['emoji' => Emoji::globeShowingEuropeAfrica(), 'enabled' => $this->isUseGlobalCert() ? 'enabled' : 'disabled']); + + // Determine forced update interval. + if ($this->settings->has('bouncer/forced_update_interval_seconds')) { + $this->setForcedUpdateIntervalSeconds($this->settings->get('bouncer/forced_update_interval_seconds')); + } + $this->logger->debug(' > Forced Update Interval is {state}', ['emoji' => Emoji::watch(), 'state' => $this->getForcedUpdateIntervalSeconds() > 0 ? $this->getForcedUpdateIntervalSeconds() : 'disabled']); + + // Determine maximum notices for nginx config creation. + if ($this->settings->has('bouncer/max_nginx_config_creation_notices')) { + $maxConfigCreationNotices = $this->settings->get('bouncer/max_nginx_config_creation_notices'); + $originalMaximumNginxConfigCreationNotices = $this->getMaximumNginxConfigCreationNotices(); + $this->setMaximumNginxConfigCreationNotices($maxConfigCreationNotices); + $this->logger->debug(' > Maximum Nginx config creation notices has been over-ridden: {original} => {new}', ['emoji' => Emoji::hikingBoot(), 'original' => $originalMaximumNginxConfigCreationNotices, 'new' => $this->getMaximumNginxConfigCreationNotices()]); + } + + // State if non-SSL is allowed. This is processed in the Target class. + $this->logger->debug(' > Allow non-SSL is {enabled}', ['emoji' => Emoji::ship(), 'enabled' => $this->settings->get('ssl/allow_non_ssl') ? 'enabled' : 'disabled']); try { $this->stateHasChanged(); } catch (ConnectException $connectException) { - $this->logger->critical('Could not connect to docker socket! Did you map it?', ['emoji' => Emoji::cryingCat()]); + $this->logger->critical('Could not connect to docker socket! Did you forget to map it?', ['emoji' => Emoji::cryingCat()]); - exit; + exit(1); } while (true) { $this->runLoop(); @@ -326,64 +365,78 @@ class Bouncer public function parseContainerEnvironmentVariables(array $envs, Target $bouncerTarget): Target { - foreach ($envs as $eKey => $eVal) { - if(empty($eVal)){ - $this->logger->warning("{key} set for {target} is empty, skipping.", ['key' => $eKey, 'emoji' => Emoji::warning(), 'target' => $bouncerTarget->getName()]); - continue; - } - switch ($eKey) { + // Process label and name specifically before all else. + foreach (array_filter($envs) as $envKey => $envVal) { + switch ($envKey) { case 'BOUNCER_LABEL': - $bouncerTarget->setLabel($eVal); + $bouncerTarget->setLabel($envVal); break; case 'BOUNCER_DOMAIN': - $domains = explode(',', $eVal); + $domains = explode(',', $envVal); array_walk($domains, function (&$domain, $key): void { $domain = trim($domain); }); $bouncerTarget->setDomains($domains); break; - + } + } + foreach (array_filter($envs) as $envKey => $envVal) { + switch ($envKey) { case 'BOUNCER_AUTH': - [$username, $password] = explode(':', $eVal); + [$username, $password] = explode(':', $envVal); $bouncerTarget->setAuth($username, $password); + // $this->logger->info('{label}: Basic Auth has been enabled.', ['emoji' => Emoji::key(), 'label' => $bouncerTarget->getLabel(),]); break; case 'BOUNCER_HOST_OVERRIDE': - $bouncerTarget->setHostOverride($eVal); + $bouncerTarget->setHostOverride($envVal); + $this->logger->warning('{label}: Host reported to container overridden and set to {host_override}.', ['emoji' => Emoji::hikingBoot() . ' ', 'label' => $bouncerTarget->getLabel(), 'host_override' => $bouncerTarget->getHostOverride()]); break; case 'BOUNCER_LETSENCRYPT': - $bouncerTarget->setLetsEncrypt(in_array(strtolower($eVal), ['yes', 'true'], true)); + $bouncerTarget->setLetsEncrypt(in_array(strtolower($envVal), ['yes', 'true'], true)); + + break; + + case 'BOUNCER_CERT': + $bouncerTarget->setCustomCert($envVal); + $this->logger->info('{label}: Custom cert specified', ['emoji' => Emoji::locked(), 'label' => $bouncerTarget->getLabel()]); + + break; + + case 'BOUNCER_CERT_KEY': + $bouncerTarget->setCustomCertKey($envVal); break; case 'BOUNCER_TARGET_PORT': - $bouncerTarget->setPort(intval($eVal)); + $bouncerTarget->setPort(intval($envVal)); + // $this->logger->info('{label}: Target port set to {port}.', ['emoji' => Emoji::ship(), 'label' => $bouncerTarget->getLabel(), 'port' => $bouncerTarget->getPort(),]); break; case 'BOUNCER_ALLOW_NON_SSL': - $bouncerTarget->setAllowNonSSL(in_array(strtolower($eVal), ['yes', 'true'], true)); + $bouncerTarget->setAllowNonSSL(in_array(strtolower($envVal), ['yes', 'true'], true)); break; case 'BOUNCER_ALLOW_WEBSOCKETS': - $bouncerTarget->setAllowWebsocketSupport(in_array(strtolower($eVal), ['yes', 'true'], true)); + $bouncerTarget->setAllowWebsocketSupport(in_array(strtolower($envVal), ['yes', 'true'], true)); break; case 'BOUNCER_ALLOW_LARGE_PAYLOADS': - $bouncerTarget->setAllowLargePayloads(in_array(strtolower($eVal), ['yes', 'true'], true)); + $bouncerTarget->setAllowLargePayloads(in_array(strtolower($envVal), ['yes', 'true'], true)); break; case 'BOUNCER_PROXY_TIMEOUT_SECONDS': - $bouncerTarget->setProxyTimeoutSeconds(is_numeric($eVal) ? intval($eVal) : null); + $bouncerTarget->setProxyTimeoutSeconds(is_numeric($envVal) ? intval($envVal) : null); break; } @@ -456,13 +509,13 @@ class Bouncer if ($this->lastUpdateEpoch === null) { $isTainted = true; } elseif ($this->forcedUpdateIntervalSeconds > 0 && $this->lastUpdateEpoch <= time() - $this->forcedUpdateIntervalSeconds) { - $this->logger->warning(' Forced update interval of {interval_seconds} seconds has been reached, forcing update.', ['emoji' => Emoji::watch(), 'interval_seconds' => $this->forcedUpdateIntervalSeconds]); + $this->logger->warning('Forced update interval of {interval_seconds} seconds has been reached, forcing update.', ['emoji' => Emoji::watch(), 'interval_seconds' => $this->forcedUpdateIntervalSeconds]); $isTainted = true; } elseif ($this->previousContainerState === []) { - $this->logger->warning(' Initial state has not been set, forcing update.', ['emoji' => Emoji::watch()]); + $this->logger->warning('Initial state has not been set, forcing update.', ['emoji' => Emoji::watch()]); $isTainted = true; } elseif ($this->previousSwarmState === []) { - $this->logger->warning(' Initial swarm state has not been set, forcing update.', ['emoji' => Emoji::watch()]); + $this->logger->warning('Initial swarm state has not been set, forcing update.', ['emoji' => Emoji::watch()]); $isTainted = true; } @@ -493,8 +546,8 @@ class Bouncer // Calculate Container State Hash $containerStateDiff = $this->diff($this->previousContainerState, $newContainerState); if (!$isTainted && !empty($containerStateDiff)) { - if($this->settings->if('logger/show_state_deltas')) { - $this->logger->warning(' Container state has changed', ['emoji' => Emoji::warning()]); + if ($this->settings->if('logger/show_state_deltas')) { + $this->logger->warning('Container state has changed', ['emoji' => Emoji::warning() . ' ']); echo $containerStateDiff; } $isTainted = true; @@ -506,7 +559,7 @@ class Bouncer if ($this->isSwarmMode()) { $services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true); if (isset($services['message'])) { - $this->logger->warning('Something happened while interrogating services.. This node is not a swarm node, cannot have services: {message}', ['emoji' => Emoji::warning(), 'message' => $services['message']]); + $this->logger->warning('Something happened while interrogating services.. This node is not a swarm node, cannot have services: {message}', ['emoji' => Emoji::warning() . ' ', 'message' => $services['message']]); } else { foreach ($services as $service) { $name = $service['Spec']['Name']; @@ -533,8 +586,8 @@ class Bouncer // Calculate Swarm State Hash, if applicable $swarmStateDiff = $this->diff($this->previousSwarmState, $newSwarmState); if ($this->isSwarmMode() && !$isTainted && !empty($swarmStateDiff)) { - if($this->settings->if('logger/show_state_deltas')){ - $this->logger->warning(' Swarm state has changed', ['emoji' => Emoji::warning()]); + if ($this->settings->if('logger/show_state_deltas')) { + $this->logger->warning('Swarm state has changed', ['emoji' => Emoji::warning() . ' ']); echo $swarmStateDiff; } $isTainted = true; @@ -570,13 +623,13 @@ class Bouncer } catch (ServerException $exception) { $this->setSwarmMode(false); } catch (ConnectException $exception) { - $this->logger->critical('Unable to connect to docker socket!', ['emoji' => Emoji::warning()]); + $this->logger->critical('Unable to connect to docker socket!', ['emoji' => Emoji::warning() . ' ']); $this->logger->critical($exception->getMessage()); exit(1); } - $this->logger->info('Swarm mode is {enabled}.', ['emoji' => Emoji::honeybee(), 'enabled' => $this->isSwarmMode() ? 'enabled' : 'disabled']); + $this->logger->debug(' > Swarm mode is {enabled}.', ['emoji' => Emoji::honeybee(), 'enabled' => $this->isSwarmMode() ? 'enabled' : 'disabled']); $targets = array_values( array_merge( @@ -668,7 +721,7 @@ class Bouncer private function writeCertificatesToS3(): void { - $this->logger->info(' Uploading Certificates to S3', ['emoji' => Emoji::CHARACTER_UP_ARROW]); + $this->logger->info('Uploading Certificates to S3', ['emoji' => Emoji::CHARACTER_UP_ARROW]); foreach ($this->certificateStoreLocal->listContents('/archive', true) as $file) { /** @var FileAttributes $file */ if ($file->isFile()) { @@ -696,20 +749,12 @@ class Bouncer $changedTargets[strrev($target->getName())] = $target; } } - /** - * @var Target[] $changedTargets - */ + // @var Target[] $changedTargets ksort($changedTargets); $changedTargets = array_values($changedTargets); - // @todo MB: it'd be nice if this'd explode the domains and tree-walk them like: - // com - // |- example - // | |- www - // | |- api - // and so on. - if (count($changedTargets) <= $this->getMaximumNginxConfigCreationNotices()) { + /** @var Target $target */ foreach ($changedTargets as $target) { $context = [ 'label' => $target->getLabel(), @@ -717,20 +762,23 @@ class Bouncer 'file' => $target->getNginxConfigFileName(), 'config_dir' => Bouncer::FILESYSTEM_CONFIG_DIR, ]; - $this->logger->info('Created {label}', $context + ['emoji' => Emoji::pencil() . " "]); - $this->logger->debug(' -> {config_dir}/{file}', $context); - $this->logger->debug(' -> {domain}', $context); + $this->logger->info('Created {label}', $context + ['emoji' => Emoji::pencil() . ' ']); + $this->logger->debug(' -> {config_dir}/{file}', $context + ['emoji' => Emoji::pencil() . ' ']); + $this->logger->debug(' -> {domain}', $context + ['emoji' => Emoji::pencil() . ' ']); + $this->logger->critical('{label} cert type is {cert_type}', $context + ['emoji' => Emoji::catFace() . ' ', 'cert_type' => $target->getTypeCertInUse()->name]); } } else { - $this->logger->info(' More than {num_max} Nginx configs generated.. Too many to show them all!', ['emoji' => Emoji::pencil() . " ", 'num_max' => $this->getMaximumNginxConfigCreationNotices()]); + $this->logger->info('More than {num_max} Nginx configs generated.. Too many to show them all!', ['emoji' => Emoji::pencil() . ' ', 'num_max' => $this->getMaximumNginxConfigCreationNotices()]); } - $this->logger->info('Updated {num_created} Nginx configs, {num_changed} changed..', ['emoji' => Emoji::pencil() . " ", 'num_created' => count($targets), 'num_changed' => count($changedTargets)]); + $this->logger->info('Updated {num_created} Nginx configs, {num_changed} changed..', ['emoji' => Emoji::pencil() . ' ', 'num_created' => count($targets), 'num_changed' => count($changedTargets)]); $this->pruneNonExistentConfigs($targets); } /** * @param $targets Target[] + * + * @throws FilesystemException */ protected function pruneNonExistentConfigs(array $targets): void { @@ -742,12 +790,18 @@ class Bouncer } foreach ($this->configFilesystem->listContents('/') as $file) { if (!in_array($file['path'], $expectedFiles)) { - $this->logger->info(' Removing {file}', ['emoji' => Emoji::wastebasket(), 'file' => $file['path']]); + $this->logger->info('Removing {file}', ['emoji' => Emoji::wastebasket(), 'file' => $file['path']]); $this->configFilesystem->delete($file['path']); } } } + /** + * @throws FilesystemException + * @throws LoaderError + * @throws RuntimeError + * @throws SyntaxError + */ private function generateNginxConfig(Target $target): bool { $configData = $this->twig->render('NginxTemplate.twig', $target->__toArray()); @@ -759,6 +813,11 @@ class Bouncer $changed = true; } + if ($target->isUseCustomCert()) { + $this->configFilesystem->write($target->getCustomCertPath(), $target->getCustomCert()); + $this->configFilesystem->write($target->getCustomCertKeyPath(), $target->getCustomCertKey()); + } + if ($target->hasAuth()) { $authFileHash = $this->configFilesystem->fileExists($target->getBasicAuthFileName()) ? $this->configFilesystem->read($target->getBasicAuthHashFileName()) : null; if ($target->getAuthHash() != $authFileHash) { @@ -834,13 +893,13 @@ class Bouncer $command->addFlag('n'); $command->addFlag('m', $this->environment['BOUNCER_LETSENCRYPT_EMAIL']); $command->addArgument('agree-tos'); - $this->logger->info('Generating letsencrypt for {target_name} - {command}', ['emoji' => Emoji::pencil() . " ", 'target_name' => $target->getName(), 'command' => $command->__toString()]); + $this->logger->info('Generating letsencrypt for {target_name} - {command}', ['emoji' => Emoji::pencil() . ' ', 'target_name' => $target->getName(), 'command' => $command->__toString()]); $shell->run($command); if ($shell->getReturnValue() == 0) { $this->logger->info('Generating successful', ['emoji' => Emoji::partyPopper()]); } else { - $this->logger->critical('Generating failed!', ['emoji' => Emoji::warning()]); + $this->logger->critical('Generating failed!', ['emoji' => Emoji::warning() . ' ']); } // Re-enable nginx tweaks @@ -863,18 +922,7 @@ class Bouncer $shell = new Exec(); $command = new CommandBuilder('/usr/sbin/nginx'); $command->addFlag('s', 'reload'); - $this->logger->info('Restarting nginx', ['emoji' => Emoji::timerClock() . " "]); + $this->logger->info('Restarting nginx', ['emoji' => Emoji::timerClock() . ' ']); $shell->run($command); } - - private function wipeNginxConfig(): void - { - $this->logger->debug('Purging existing config files ...', ['emoji' => Emoji::bomb()]); - foreach ($this->configFilesystem->listContents('') as $file) { - /** @var FileAttributes $file */ - if ($file->isFile() && $file->path() != 'default.conf' && $file->path() != 'default-ssl.conf') { - $this->configFilesystem->delete($file->path()); - } - } - } } diff --git a/bouncer/src/EnumCertType.php b/bouncer/src/EnumCertType.php new file mode 100644 index 0000000..6e41d32 --- /dev/null +++ b/bouncer/src/EnumCertType.php @@ -0,0 +1,18 @@ +settings = [ + 'build' => [ + 'sha' => Settings::getEnvironment('GIT_SHA', 'unknown'), + 'sha_short' => substr(Settings::getEnvironment('GIT_SHA', 'unknown'), 0, 7), + 'id' => Settings::getEnvironment('GIT_BUILD_ID', 'unknown'), + 'date' => Carbon::parse(Settings::getEnvironment('BUILD_DATE')), + 'message' => trim(Settings::getEnvironment('GIT_COMMIT_MESSAGE', '')), + ], 'logger' => [ 'name' => Settings::getEnvironment('LOG_NAME', 'bouncer'), 'path' => Settings::getEnvironment('LOG_FILE', '/var/log/bouncer/bouncer.log'), @@ -51,8 +59,18 @@ class Settings implements SettingsInterface 'coloured_output' => Settings::isEnabled('LOG_COLOUR', true), 'show_state_deltas' => Settings::isEnabled('LOG_SHOW_STATE_DELTAS'), ], + 'ssl' => [ + 'allow_non_ssl' => Settings::isEnabled('BOUNCER_ALLOW_NON_SSL', true), + 'global_cert' => Settings::getEnvironment('GLOBAL_CERT'), + 'global_cert_key' => Settings::getEnvironment('GLOBAL_CERT_KEY'), + ], + 'bouncer' => [ + 'http_port' => intval(Settings::getEnvironment('BOUNCER_HTTP_PORT', 80)), + 'https_port' => intval(Settings::getEnvironment('BOUNCER_HTTPS_PORT', 443)), + 'forced_update_interval_seconds' => intval(Settings::getEnvironment('BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS', 0)), + 'max_nginx_config_creation_notices' => intval(Settings::getEnvironment('BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES', 15)), + ], ]; - \Kint::dump($this->settings); } public function get(string $key = '', mixed $default = null): mixed diff --git a/bouncer/src/Target.php b/bouncer/src/Target.php index 6efe909..ce5c12c 100644 --- a/bouncer/src/Target.php +++ b/bouncer/src/Target.php @@ -5,6 +5,7 @@ declare(strict_types=1); namespace Bouncer; use Bouncer\Logger\Logger; +use Bouncer\Settings\Settings; use Spatie\Emoji\Emoji; class Target @@ -16,9 +17,11 @@ class Target private ?int $port = null; private bool $letsEncrypt = false; private string $targetPath; - private bool $allowNonSSL = true; - private bool $useTemporaryCert = true; + private bool $allowNonSSL; + private bool $useTemporaryCert = false; private bool $useGlobalCert = false; + private ?string $customCert = null; + private ?string $customCertKey = null; private bool $allowWebsocketSupport = true; private bool $allowLargePayloads = false; private ?int $proxyTimeoutSeconds = null; @@ -28,29 +31,41 @@ class Target private ?string $hostOverride = null; public function __construct( - private Logger $logger + private Logger $logger, + private Settings $settings, ) { + $this->allowNonSSL = $this->settings->get('ssl/allow_non_ssl', true); } public function __toArray() { + if ($this->settings->has('ssl/global_cert') && $this->settings->get('ssl/global_cert') === true) { + if ($this->getTypeCertInUse() != EnumCertType::GLOBAL_CERT) { + $this->logger->debug('{label} has overridden cert type of {cert_type}', ['emoji' => Emoji::exclamationQuestionMark() . ' ', 'label' => $this->getLabel(), 'cert_type' => $this->getTypeCertInUse()->name]); + } + } + return [ - 'id' => $this->getId(), - 'name' => $this->getName(), - 'label' => $this->getLabel(), - 'domains' => $this->getDomains(), - 'letsEncrypt' => $this->isLetsEncrypt(), - 'targetPath' => $this->getTargetPath(), - 'useTemporaryCert' => $this->isUseTemporaryCert(), - 'useGlobalCert' => $this->isUseGlobalCert(), - 'allowNonSSL' => $this->isAllowNonSSL(), - 'allowWebsocketSupport' => $this->isAllowWebsocketSupport(), - 'allowLargePayloads' => $this->isAllowLargePayloads(), - 'proxyTimeoutSeconds' => $this->getProxyTimeoutSeconds(), - 'hasAuth' => $this->hasAuth(), - 'authFile' => $this->getBasicAuthFileName(), - 'hasHostOverride' => $this->hasHostOverride(), - 'hostOverride' => $this->getHostOverride(), + 'portHttp' => $this->settings->get('bouncer/http_port'), + 'portHttps' => $this->settings->get('bouncer/https_port'), + ] + [ + 'id' => $this->getId(), + 'name' => $this->getName(), + 'label' => $this->getLabel(), + 'serverName' => $this->getNginxServerName(), + 'certType' => $this->getTypeCertInUse()->name, + 'targetPath' => $this->getTargetPath(), + 'customCertFile' => $this->getCustomCertPath(), + 'customCertKeyFile' => $this->getCustomCertKeyPath(), + 'useCustomCert' => $this->isUseCustomCert(), + 'allowNonSSL' => $this->isAllowNonSSL(), + 'allowWebsocketSupport' => $this->isAllowWebsocketSupport(), + 'allowLargePayloads' => $this->isAllowLargePayloads(), + 'proxyTimeoutSeconds' => $this->getProxyTimeoutSeconds(), + 'hasAuth' => $this->hasAuth(), + 'authFile' => $this->getBasicAuthFileName(), + 'hasHostOverride' => $this->hasHostOverride(), + 'hostOverride' => $this->getHostOverride(), ]; } @@ -98,6 +113,35 @@ class Target return $this; } + public function getCustomCert(): ?string + { + return $this->customCert; + } + + public function setCustomCert(?string $customCert): Target + { + $this->customCert = $customCert; + + return $this; + } + + public function getCustomCertKey(): ?string + { + return $this->customCertKey; + } + + public function setCustomCertKey(?string $customCertKey): Target + { + $this->customCertKey = $customCertKey; + + return $this; + } + + public function isUseCustomCert(): bool + { + return $this->customCert !== null && $this->customCertKey !== null; + } + public function getAuth(): array { return [ @@ -136,6 +180,16 @@ class Target return "{$this->getBasicAuthFileName()}.hash"; } + public function getCustomCertPath(): string + { + return "{$this->getName()}.public.pem"; + } + + public function getCustomCertKeyPath(): string + { + return "{$this->getName()}.private.pem"; + } + public function getBasicAuthFileData(): string { $output = shell_exec(sprintf('htpasswd -nibB -C10 %s %s', $this->getUsername(), $this->getPassword())); @@ -152,6 +206,8 @@ class Target $this->getNginxConfigFileName(), $this->hasAuth() ? $this->getBasicAuthFileName() : null, $this->hasAuth() ? $this->getBasicAuthHashFileName() : null, + $this->isUseCustomCert() ? $this->getCustomCertPath() : null, + $this->isUseCustomCert() ? $this->getCustomCertKeyPath() : null, ]); } @@ -186,16 +242,23 @@ class Target public function setUseGlobalCert(bool $useGlobalCert): self { + // $this->logger->critical('setUseGlobalCert: {useGlobalCert}', ['useGlobalCert' => $useGlobalCert ? 'yes' : 'no']); $this->useGlobalCert = $useGlobalCert; - // Global cert overrides temporary certs. - if ($useGlobalCert) { - $this->setUseTemporaryCert(false); - } - return $this; } + public function getTypeCertInUse(): EnumCertType + { + return match (true) { + $this->isUseCustomCert() => EnumCertType::CUSTOM_CERT, + $this->isLetsEncrypt() => EnumCertType::LETSENCRYPT_CERT, + $this->isUseTemporaryCert() => EnumCertType::TEMPORARY_CERT, + $this->isUseGlobalCert() => EnumCertType::GLOBAL_CERT, + default => EnumCertType::NO_CERT, + }; + } + public function isAllowWebsocketSupport(): bool { return $this->allowWebsocketSupport; @@ -240,6 +303,25 @@ class Target return $this->domains; } + public function getNginxServerNames(): array + { + $serverNames = []; + foreach ($this->domains as $domain) { + if (stripos($domain, '*') !== false) { + $serverNames[] = sprintf('~^(.*)%s$', str_replace('*', '', $domain)); + } else { + $serverNames[] = $domain; + } + } + + return $serverNames; + } + + public function getNginxServerName(): string + { + return implode(' ', $this->getNginxServerNames()); + } + /** * @param string[] $domains */ @@ -304,12 +386,12 @@ class Target return $this; } - public function getName() + public function getName(): string { - return reset($this->domains); + return str_replace('*.', '', reset($this->domains)); } - public function getLabel() + public function getLabel(): string { return $this->label ?? $this->getName(); } diff --git a/bouncer/templates/NginxTemplate.twig b/bouncer/templates/NginxTemplate.twig index 595691f..0457dca 100644 --- a/bouncer/templates/NginxTemplate.twig +++ b/bouncer/templates/NginxTemplate.twig @@ -1,21 +1,24 @@ server { {% if allowNonSSL %} - listen 80; - listen [::]:80; + listen {{ portHttp }}; + listen [::]:{{ portHttp }}; {% endif %} - listen 443 ssl; - listen [::]:443 ssl; - server_name {{ domains|join(' ') }}; + listen {{ portHttps }} ssl; + listen [::]:{{ portHttps }} ssl; + server_name {{ serverName }}; access_log /var/log/bouncer/{{ name }}.access.log; error_log /var/log/bouncer/{{ name }}.error.log; -{% if useTemporaryCert %} +{% if certType == 'TEMPORARY_CERT' %} ssl_certificate /certs/example.crt; ssl_certificate_key /certs/example.key; -{% elseif useGlobalCert %} +{% elseif certType == 'GLOBAL_CERT' %} ssl_certificate /certs/global.crt; ssl_certificate_key /certs/global.key; -{% else %} +{% elseif certType == 'CUSTOM_CERT' %} + ssl_certificate /etc/nginx/sites-enabled/{{ customCertFile }}; + ssl_certificate_key /etc/nginx/sites-enabled/{{ customCertKeyFile }}; +{% elseif certType == 'LETSENCRYPT_CERT' %} ssl_certificate /etc/letsencrypt/live/{{ name }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ name }}/privkey.pem; {% endif %} @@ -60,9 +63,9 @@ server { {% if not allowNonSSL %} server { - listen 80; - listen [::]:80; - server_name {{ domains|join(' ') }}; + listen {{ portHttp }}; + listen [::]:{{ portHttp }}; + server_name {{ serverName }}; return 301 https://$host$request_uri; } {% endif %}