Custom certificate support. Wildcard domain support

This commit is contained in:
Greyscale 2024-02-08 16:48:07 +01:00
parent 52aa04c59c
commit a406e895d2
No known key found for this signature in database
GPG key ID: 74BAFF55434DA4B2
12 changed files with 393 additions and 202 deletions

View file

@ -22,6 +22,12 @@ jobs:
name: Bake Bouncer Container name: Bake Bouncer Container
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: "Setup: Checkout Source"
uses: actions/checkout@v4
with:
sparse-checkout: |
bouncer
- name: "Setup: Get Date" - name: "Setup: Get Date"
id: date id: date
run: | run: |
@ -35,7 +41,7 @@ jobs:
- name: "Setup: PHP" - name: "Setup: PHP"
uses: shivammathur/setup-php@v2 uses: shivammathur/setup-php@v2
with: with:
php-version: 8.1 php-version: 8.2
env: env:
runner: self-hosted runner: self-hosted
@ -61,12 +67,6 @@ jobs:
username: matthewbaggett username: matthewbaggett
password: ${{ secrets.GHCR_PASSWORD }} password: ${{ secrets.GHCR_PASSWORD }}
- name: "Setup: Checkout Source"
uses: actions/checkout@v4
with:
sparse-checkout: |
bouncer
- name: "Setup: Configure Cache" - name: "Setup: Configure Cache"
uses: actions/cache@v4 uses: actions/cache@v4
with: with:
@ -90,6 +90,7 @@ jobs:
push: true push: true
build-args: | build-args: |
GIT_SHA=${{ github.sha }} GIT_SHA=${{ github.sha }}
GIT_BUILD_ID=${{ github.ref_name }}
BUILD_DATE=${{ steps.date.outputs.container_build_datetime }} BUILD_DATE=${{ steps.date.outputs.container_build_datetime }}
GIT_COMMIT_MESSAGE=${{ github.event.head_commit.message }} GIT_COMMIT_MESSAGE=${{ github.event.head_commit.message }}
tags: | tags: |

View file

@ -1,9 +1,11 @@
# checkov:skip=CKV_DOCKER_3 user cannot be determined at this stage.
FROM php:cli as bouncer FROM php:cli as bouncer
LABEL maintainer="Matthew Baggett <matthew@baggett.me>" \ LABEL maintainer="Matthew Baggett <matthew@baggett.me>" \
org.label-schema.vcs-url="https://github.com/benzine-framework/docker" \ org.label-schema.vcs-url="https://github.com/benzine-framework/docker" \
org.opencontainers.image.source="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 # ts:skip=AC_DOCKER_0002 Mis-detecting usage of apt instead of apt-get
# Install nginx, certbot # Install nginx, certbot
RUN apt-get -qq update && \ RUN apt-get -qq update && \
@ -39,12 +41,12 @@ COPY self-signed-certificates /certs
# Install runits for services # Install runits for services
COPY nginx.runit /etc/service/nginx/run COPY nginx.runit /etc/service/nginx/run
COPY logs.runit /etc/service/nginx-logs/run #COPY logs.runit /etc/service/nginx-logs/run
COPY logs.finish /etc/service/nginx-logs/finish #COPY logs.finish /etc/service/nginx-logs/finish
COPY bouncer.runit /etc/service/bouncer/run COPY bouncer.runit /etc/service/bouncer/run
COPY bouncer.finish /etc/service/bouncer/finish COPY bouncer.finish /etc/service/bouncer/finish
COPY logs-nginx-access.runit /etc/service/logs-nginx-access/run #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-error.runit /etc/service/logs-nginx-error/run
RUN chmod +x /etc/service/*/run /etc/service/*/finish RUN chmod +x /etc/service/*/run /etc/service/*/finish
# Copy default nginx bits # Copy default nginx bits
@ -69,6 +71,16 @@ COPY src /app/src
COPY templates /app/templates COPY templates /app/templates
RUN chmod +x /app/bin/bouncer 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 # Create some volumes for logs and certs
VOLUME /etc/letsencrypt VOLUME /etc/letsencrypt
VOLUME /var/log/bouncer VOLUME /var/log/bouncer
@ -77,32 +89,26 @@ VOLUME /var/log/bouncer
EXPOSE 80 EXPOSE 80
EXPOSE 443 EXPOSE 443
# Down-privelege to bouncer
USER bouncer
# Set a healthcheck to curl the bouncer and expect a 200 # Set a healthcheck to curl the bouncer and expect a 200
HEALTHCHECK --start-period=30s \ HEALTHCHECK --start-period=30s \
CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1
# stuff some envs from build RUN printenv | sort
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}
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 COPY ./test/public-web-a /app/public
HEALTHCHECK --start-period=30s \ HEALTHCHECK --start-period=30s \
CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 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 COPY ./test/public-web-b /app/public
HEALTHCHECK --start-period=30s \ HEALTHCHECK --start-period=30s \
CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 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 COPY ./test/public-web-c /app/public
HEALTHCHECK --start-period=30s \ HEALTHCHECK --start-period=30s \
CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1 CMD curl -s -o /dev/null -w "200" http://localhost:80/ || exit 1

View file

@ -4,17 +4,21 @@ php-cs-fixer:
fix: php-cs-fixer fix: php-cs-fixer
image_reference_devel:=ghcr.io/benzine-framework/bouncer:devel
build-n-push: fix build-n-push: fix
docker build \ docker build \
--build-arg BUILD_DATE="$(shell date -u +"%Y-%m-%dT%H:%M:%SZ")" \ --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_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-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 benzine/bouncer \
--tag ghcr.io/benzine-framework/bouncer \ --tag $(image_reference_devel) \
--target bouncer \ --target bouncer \
--progress plain \
. .
#docker push benzine/bouncer docker push $(image_reference_devel)
docker push ghcr.io/benzine-framework/bouncer
test-as-service: clean test-as-service: clean
docker build -t bouncer --target bouncer . docker build -t bouncer --target bouncer .

View file

@ -9,19 +9,20 @@ These should not be confused.
#### Main configuration #### Main configuration
| Key | Default | Options | Behaviour | | 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 | | 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 | 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. | | 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_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_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 | | 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_FILE | /var/log/bouncer/bouncer.log | | The path to the log file to write to | | LOG_NAME | bouncer | | The name of 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_FILE | /var/log/bouncer/bouncer.log | | The path to the log file to write to |
| LOG_LEVEL_NAME_LENGTH | 4 | positive numbers | The length of the level name to be written to the log file. See Monolog docs. | | LOG_LEVEL | debug | info, debug, critical etc | The level of logging to write 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_LEVEL_NAME_LENGTH | 4 | positive numbers | The length of the level name to be written to the log file. See Monolog docs. |
| LOG_COLOUR | true | true, false | Whether to colourise the log output sent to stdout. | | | 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: #### 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 #### For using S3-compatable storage for generated cert synchronisation with Lets Encrypt
| Key | Default | Options | Behaviour | | Key | Default | Options | Behaviour |
| ---------------------------------- | ------- | --------------- | ------------------------------------------------------------------------------------- | --- | | ---------------------------------- | ------- | --------------- | ------------------------------------------------------------------------------------- |
| BOUNCER_S3_BUCKET | false | | enable S3 behaviour to store lets-encrypt generated certs | | 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_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_KEY_SECRET | false | | S3 API Key Secret |
| BOUNCER_S3_REGION | false | | S3 API Region | | BOUNCER_S3_REGION | false | | S3 API Region |
| BOUNCER_S3_USE_PATH_STYLE_ENDPOINT | false | `true or false` | Needed for minio | | 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_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_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_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_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_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_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_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_WEBSOCKETS | Defaults to enabled. Values are "yes" or "true", anything else is false | Enable websocket behaviour |

View file

@ -1,4 +1,5 @@
#!/usr/bin/env bash #!/usr/bin/env bash
sleep 3 sleep 3
printf "\n\n\n\n"
echo "Starting Bouncer" echo "Starting Bouncer"
/app/bin/bouncer /app/bin/bouncer

View file

@ -1,11 +1,13 @@
version: "3.4" version: "3.5"
services: services:
bouncer: bouncer:
image: bouncer image: ghcr.io/benzine-framework/bouncer:devel
build: build:
context: . context: .
target: bouncer target: bouncer
additional_contexts:
- php:cli=docker-image://ghcr.io/benzine-framework/php:cli-8.2
volumes: volumes:
- /var/run/docker.sock:/var/run/docker.sock - /var/run/docker.sock:/var/run/docker.sock
- ./:/app - ./:/app
@ -26,6 +28,8 @@ services:
build: build:
context: . context: .
target: test-app-a target: test-app-a
additional_contexts:
- php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2
volumes: volumes:
- ./test/public-web-a:/app/public - ./test/public-web-a:/app/public
environment: environment:
@ -38,6 +42,8 @@ services:
build: build:
context: . context: .
target: test-app-b target: test-app-b
additional_contexts:
- php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2
volumes: volumes:
- ./test/public-web-b:/app/public - ./test/public-web-b:/app/public
environment: environment:

View file

@ -1,4 +1,4 @@
#!/usr/bin/env bash #!/usr/bin/env bash
#if [[ -f /var/log/bouncer/bouncer.log ]]; then if [[ -f /var/log/bouncer/bouncer.log ]]; then
# tail -f /var/log/bouncer/bouncer.log tail -f /var/log/bouncer/bouncer.log
#fi fi

View file

@ -7,7 +7,6 @@ namespace Bouncer;
use AdamBrett\ShellWrapper\Command\Builder as CommandBuilder; use AdamBrett\ShellWrapper\Command\Builder as CommandBuilder;
use AdamBrett\ShellWrapper\Runners\Exec; use AdamBrett\ShellWrapper\Runners\Exec;
use Aws\S3\S3Client; use Aws\S3\S3Client;
use Carbon\Carbon;
use GuzzleHttp\Client as Guzzle; use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Exception\ConnectException; use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException; use GuzzleHttp\Exception\ServerException;
@ -21,6 +20,9 @@ use Bouncer\Logger\Formatter;
use Spatie\Emoji\Emoji; use Spatie\Emoji\Emoji;
use Symfony\Component\Yaml\Yaml; use Symfony\Component\Yaml\Yaml;
use Twig\Environment as Twig; use Twig\Environment as Twig;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader as TwigLoader; use Twig\Loader\FilesystemLoader as TwigLoader;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Monolog\Processor; 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 public function getMaximumNginxConfigCreationNotices(): int
@ -194,30 +170,45 @@ class Bouncer
$containers = json_decode($this->docker->request('GET', 'containers/json')->getBody()->getContents(), true); $containers = json_decode($this->docker->request('GET', 'containers/json')->getBody()->getContents(), true);
foreach ($containers as $container) { foreach ($containers as $container) {
$envs = []; $envs = [];
$inspect = json_decode($this->docker->request('GET', "containers/{$container['Id']}/json")->getBody()->getContents(), true); $container = json_decode($this->docker->request('GET', "containers/{$container['Id']}/json")->getBody()->getContents(), true);
if (isset($inspect['Config']['Env'])) { if (
foreach ($inspect['Config']['Env'] as $environmentItem) { !isset($container['Config']['Env'])
if (stripos($environmentItem, '=') !== false) { ) {
[$envKey, $envVal] = explode('=', $environmentItem, 2); continue;
$envs[$envKey] = $envVal; }
} else { // Parse all the environment variables and store them in an array.
$envs[$environmentItem] = true; 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'])) { if (isset($envs['BOUNCER_DOMAIN'])) {
$bouncerTarget = (new Target($this->logger)) $bouncerTarget = (new Target(
->setId($inspect['Id']) logger: $this->logger,
settings: $this->settings,
))
->setId($container['Id'])
; ;
$bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget); $bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget);
if (isset($inspect['NetworkSettings']['IPAddress']) && !empty($inspect['NetworkSettings']['IPAddress'])) { if (!empty($container['NetworkSettings']['IPAddress'])) {
// As per docker service // As per docker service
$bouncerTarget->setEndpointHostnameOrIp($inspect['NetworkSettings']['IPAddress']); $bouncerTarget->setEndpointHostnameOrIp($container['NetworkSettings']['IPAddress']);
} else { } else {
// As per docker compose // As per docker compose
$networks = array_values($inspect['NetworkSettings']['Networks']); $networks = array_values($container['NetworkSettings']['Networks']);
$bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']); $bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']);
} }
@ -248,7 +239,7 @@ class Bouncer
$services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true); $services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true);
if (isset($services['message'])) { 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 { } else {
foreach ($services as $service) { foreach ($services as $service) {
$envs = []; $envs = [];
@ -260,24 +251,48 @@ class Bouncer
) { ) {
continue; continue;
} }
// Parse all the environment variables and store them in an array.
foreach ($service['Spec']['TaskTemplate']['ContainerSpec']['Env'] as $env) { foreach ($service['Spec']['TaskTemplate']['ContainerSpec']['Env'] as $env) {
[$eKey, $eVal] = explode('=', $env, 2); [$envKey, $envVal] = explode('=', $env, 2);
$envs[$eKey] = $eVal; 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'])) { if (isset($envs['BOUNCER_DOMAIN'])) {
$bouncerTarget = (new Target($this->logger)) $bouncerTarget->setId($service['ID']);
->setId($service['ID']) $bouncerTarget->setLabel($service['Spec']['Name']);
;
$bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget); $bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget);
if ($bouncerTarget->isPortSet()) { if ($bouncerTarget->isPortSet()) {
$bouncerTarget->setEndpointHostnameOrIp($service['Spec']['Name']); $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'])) { } elseif (isset($service['Endpoint']['Ports'])) {
$bouncerTarget->setEndpointHostnameOrIp('172.17.0.1'); $bouncerTarget->setEndpointHostnameOrIp('172.17.0.1');
$bouncerTarget->setPort(intval($service['Endpoint']['Ports'][0]['PublishedPort'])); $bouncerTarget->setPort(intval($service['Endpoint']['Ports'][0]['PublishedPort']));
} else { } 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; continue;
} }
@ -306,18 +321,42 @@ class Bouncer
public function run(): void public function run(): void
{ {
$gitHash = substr($this->environment['GIT_SHA'], 0, 7); $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()]);
$buildDate = Carbon::parse($this->environment['BUILD_DATE']); $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')]);
$gitMessage = trim($this->environment['GIT_COMMIT_MESSAGE']); $this->logger->debug(' > HTTPS Listener is on {https_port}', ['emoji' => Emoji::ship(), 'https_port' => $this->settings->get('bouncer/https_port')]);
$this->logger->info(' Starting Bouncer. Built on {build_date}, {build_ago}', ['emoji' => Emoji::redHeart(), 'build_date' => $buildDate->toDateTimeString(), 'build_ago' => $buildDate->ago()]); $this->logger->debug(' > HTTP Listener is on {http_port}', ['emoji' => Emoji::ship(), 'http_port' => $this->settings->get('bouncer/http_port')]);
$this->logger->info(' Build #{git_sha}: "{git_message}"', ['emoji' => Emoji::memo(), 'git_sha' => $gitHash, 'git_message' => $gitMessage]);
// 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 { try {
$this->stateHasChanged(); $this->stateHasChanged();
} catch (ConnectException $connectException) { } 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) { while (true) {
$this->runLoop(); $this->runLoop();
@ -326,64 +365,78 @@ class Bouncer
public function parseContainerEnvironmentVariables(array $envs, Target $bouncerTarget): Target public function parseContainerEnvironmentVariables(array $envs, Target $bouncerTarget): Target
{ {
foreach ($envs as $eKey => $eVal) { // Process label and name specifically before all else.
if(empty($eVal)){ foreach (array_filter($envs) as $envKey => $envVal) {
$this->logger->warning("{key} set for {target} is empty, skipping.", ['key' => $eKey, 'emoji' => Emoji::warning(), 'target' => $bouncerTarget->getName()]); switch ($envKey) {
continue;
}
switch ($eKey) {
case 'BOUNCER_LABEL': case 'BOUNCER_LABEL':
$bouncerTarget->setLabel($eVal); $bouncerTarget->setLabel($envVal);
break; break;
case 'BOUNCER_DOMAIN': case 'BOUNCER_DOMAIN':
$domains = explode(',', $eVal); $domains = explode(',', $envVal);
array_walk($domains, function (&$domain, $key): void { array_walk($domains, function (&$domain, $key): void {
$domain = trim($domain); $domain = trim($domain);
}); });
$bouncerTarget->setDomains($domains); $bouncerTarget->setDomains($domains);
break; break;
}
}
foreach (array_filter($envs) as $envKey => $envVal) {
switch ($envKey) {
case 'BOUNCER_AUTH': case 'BOUNCER_AUTH':
[$username, $password] = explode(':', $eVal); [$username, $password] = explode(':', $envVal);
$bouncerTarget->setAuth($username, $password); $bouncerTarget->setAuth($username, $password);
// $this->logger->info('{label}: Basic Auth has been enabled.', ['emoji' => Emoji::key(), 'label' => $bouncerTarget->getLabel(),]);
break; break;
case 'BOUNCER_HOST_OVERRIDE': 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; break;
case 'BOUNCER_LETSENCRYPT': 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; break;
case 'BOUNCER_TARGET_PORT': 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; break;
case 'BOUNCER_ALLOW_NON_SSL': case 'BOUNCER_ALLOW_NON_SSL':
$bouncerTarget->setAllowNonSSL(in_array(strtolower($eVal), ['yes', 'true'], true)); $bouncerTarget->setAllowNonSSL(in_array(strtolower($envVal), ['yes', 'true'], true));
break; break;
case 'BOUNCER_ALLOW_WEBSOCKETS': case 'BOUNCER_ALLOW_WEBSOCKETS':
$bouncerTarget->setAllowWebsocketSupport(in_array(strtolower($eVal), ['yes', 'true'], true)); $bouncerTarget->setAllowWebsocketSupport(in_array(strtolower($envVal), ['yes', 'true'], true));
break; break;
case 'BOUNCER_ALLOW_LARGE_PAYLOADS': case 'BOUNCER_ALLOW_LARGE_PAYLOADS':
$bouncerTarget->setAllowLargePayloads(in_array(strtolower($eVal), ['yes', 'true'], true)); $bouncerTarget->setAllowLargePayloads(in_array(strtolower($envVal), ['yes', 'true'], true));
break; break;
case 'BOUNCER_PROXY_TIMEOUT_SECONDS': case 'BOUNCER_PROXY_TIMEOUT_SECONDS':
$bouncerTarget->setProxyTimeoutSeconds(is_numeric($eVal) ? intval($eVal) : null); $bouncerTarget->setProxyTimeoutSeconds(is_numeric($envVal) ? intval($envVal) : null);
break; break;
} }
@ -456,13 +509,13 @@ class Bouncer
if ($this->lastUpdateEpoch === null) { if ($this->lastUpdateEpoch === null) {
$isTainted = true; $isTainted = true;
} elseif ($this->forcedUpdateIntervalSeconds > 0 && $this->lastUpdateEpoch <= time() - $this->forcedUpdateIntervalSeconds) { } 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; $isTainted = true;
} elseif ($this->previousContainerState === []) { } 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; $isTainted = true;
} elseif ($this->previousSwarmState === []) { } 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; $isTainted = true;
} }
@ -493,8 +546,8 @@ class Bouncer
// Calculate Container State Hash // Calculate Container State Hash
$containerStateDiff = $this->diff($this->previousContainerState, $newContainerState); $containerStateDiff = $this->diff($this->previousContainerState, $newContainerState);
if (!$isTainted && !empty($containerStateDiff)) { if (!$isTainted && !empty($containerStateDiff)) {
if($this->settings->if('logger/show_state_deltas')) { if ($this->settings->if('logger/show_state_deltas')) {
$this->logger->warning(' Container state has changed', ['emoji' => Emoji::warning()]); $this->logger->warning('Container state has changed', ['emoji' => Emoji::warning() . ' ']);
echo $containerStateDiff; echo $containerStateDiff;
} }
$isTainted = true; $isTainted = true;
@ -506,7 +559,7 @@ class Bouncer
if ($this->isSwarmMode()) { if ($this->isSwarmMode()) {
$services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true); $services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true);
if (isset($services['message'])) { 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 { } else {
foreach ($services as $service) { foreach ($services as $service) {
$name = $service['Spec']['Name']; $name = $service['Spec']['Name'];
@ -533,8 +586,8 @@ class Bouncer
// Calculate Swarm State Hash, if applicable // Calculate Swarm State Hash, if applicable
$swarmStateDiff = $this->diff($this->previousSwarmState, $newSwarmState); $swarmStateDiff = $this->diff($this->previousSwarmState, $newSwarmState);
if ($this->isSwarmMode() && !$isTainted && !empty($swarmStateDiff)) { if ($this->isSwarmMode() && !$isTainted && !empty($swarmStateDiff)) {
if($this->settings->if('logger/show_state_deltas')){ if ($this->settings->if('logger/show_state_deltas')) {
$this->logger->warning(' Swarm state has changed', ['emoji' => Emoji::warning()]); $this->logger->warning('Swarm state has changed', ['emoji' => Emoji::warning() . ' ']);
echo $swarmStateDiff; echo $swarmStateDiff;
} }
$isTainted = true; $isTainted = true;
@ -570,13 +623,13 @@ class Bouncer
} catch (ServerException $exception) { } catch (ServerException $exception) {
$this->setSwarmMode(false); $this->setSwarmMode(false);
} catch (ConnectException $exception) { } 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()); $this->logger->critical($exception->getMessage());
exit(1); 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( $targets = array_values(
array_merge( array_merge(
@ -668,7 +721,7 @@ class Bouncer
private function writeCertificatesToS3(): void 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) { foreach ($this->certificateStoreLocal->listContents('/archive', true) as $file) {
/** @var FileAttributes $file */ /** @var FileAttributes $file */
if ($file->isFile()) { if ($file->isFile()) {
@ -696,20 +749,12 @@ class Bouncer
$changedTargets[strrev($target->getName())] = $target; $changedTargets[strrev($target->getName())] = $target;
} }
} }
/** // @var Target[] $changedTargets
* @var Target[] $changedTargets
*/
ksort($changedTargets); ksort($changedTargets);
$changedTargets = array_values($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()) { if (count($changedTargets) <= $this->getMaximumNginxConfigCreationNotices()) {
/** @var Target $target */
foreach ($changedTargets as $target) { foreach ($changedTargets as $target) {
$context = [ $context = [
'label' => $target->getLabel(), 'label' => $target->getLabel(),
@ -717,20 +762,23 @@ class Bouncer
'file' => $target->getNginxConfigFileName(), 'file' => $target->getNginxConfigFileName(),
'config_dir' => Bouncer::FILESYSTEM_CONFIG_DIR, 'config_dir' => Bouncer::FILESYSTEM_CONFIG_DIR,
]; ];
$this->logger->info('Created {label}', $context + ['emoji' => Emoji::pencil() . " "]); $this->logger->info('Created {label}', $context + ['emoji' => Emoji::pencil() . ' ']);
$this->logger->debug(' -> {config_dir}/{file}', $context); $this->logger->debug(' -> {config_dir}/{file}', $context + ['emoji' => Emoji::pencil() . ' ']);
$this->logger->debug(' -> {domain}', $context); $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 { } 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); $this->pruneNonExistentConfigs($targets);
} }
/** /**
* @param $targets Target[] * @param $targets Target[]
*
* @throws FilesystemException
*/ */
protected function pruneNonExistentConfigs(array $targets): void protected function pruneNonExistentConfigs(array $targets): void
{ {
@ -742,12 +790,18 @@ class Bouncer
} }
foreach ($this->configFilesystem->listContents('/') as $file) { foreach ($this->configFilesystem->listContents('/') as $file) {
if (!in_array($file['path'], $expectedFiles)) { 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']); $this->configFilesystem->delete($file['path']);
} }
} }
} }
/**
* @throws FilesystemException
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
private function generateNginxConfig(Target $target): bool private function generateNginxConfig(Target $target): bool
{ {
$configData = $this->twig->render('NginxTemplate.twig', $target->__toArray()); $configData = $this->twig->render('NginxTemplate.twig', $target->__toArray());
@ -759,6 +813,11 @@ class Bouncer
$changed = true; $changed = true;
} }
if ($target->isUseCustomCert()) {
$this->configFilesystem->write($target->getCustomCertPath(), $target->getCustomCert());
$this->configFilesystem->write($target->getCustomCertKeyPath(), $target->getCustomCertKey());
}
if ($target->hasAuth()) { if ($target->hasAuth()) {
$authFileHash = $this->configFilesystem->fileExists($target->getBasicAuthFileName()) ? $this->configFilesystem->read($target->getBasicAuthHashFileName()) : null; $authFileHash = $this->configFilesystem->fileExists($target->getBasicAuthFileName()) ? $this->configFilesystem->read($target->getBasicAuthHashFileName()) : null;
if ($target->getAuthHash() != $authFileHash) { if ($target->getAuthHash() != $authFileHash) {
@ -834,13 +893,13 @@ class Bouncer
$command->addFlag('n'); $command->addFlag('n');
$command->addFlag('m', $this->environment['BOUNCER_LETSENCRYPT_EMAIL']); $command->addFlag('m', $this->environment['BOUNCER_LETSENCRYPT_EMAIL']);
$command->addArgument('agree-tos'); $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); $shell->run($command);
if ($shell->getReturnValue() == 0) { if ($shell->getReturnValue() == 0) {
$this->logger->info('Generating successful', ['emoji' => Emoji::partyPopper()]); $this->logger->info('Generating successful', ['emoji' => Emoji::partyPopper()]);
} else { } else {
$this->logger->critical('Generating failed!', ['emoji' => Emoji::warning()]); $this->logger->critical('Generating failed!', ['emoji' => Emoji::warning() . ' ']);
} }
// Re-enable nginx tweaks // Re-enable nginx tweaks
@ -863,18 +922,7 @@ class Bouncer
$shell = new Exec(); $shell = new Exec();
$command = new CommandBuilder('/usr/sbin/nginx'); $command = new CommandBuilder('/usr/sbin/nginx');
$command->addFlag('s', 'reload'); $command->addFlag('s', 'reload');
$this->logger->info('Restarting nginx', ['emoji' => Emoji::timerClock() . " "]); $this->logger->info('Restarting nginx', ['emoji' => Emoji::timerClock() . ' ']);
$shell->run($command); $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());
}
}
}
} }

View file

@ -0,0 +1,18 @@
<?php
declare(strict_types=1);
namespace Bouncer;
enum EnumCertType
{
case NO_CERT;
case LETSENCRYPT_CERT;
case TEMPORARY_CERT;
case GLOBAL_CERT;
case CUSTOM_CERT;
}

View file

@ -4,6 +4,7 @@ declare(strict_types=1);
namespace Bouncer\Settings; namespace Bouncer\Settings;
use Carbon\Carbon;
use Monolog\Level; use Monolog\Level;
class Settings implements SettingsInterface class Settings implements SettingsInterface
@ -42,6 +43,13 @@ class Settings implements SettingsInterface
public function __construct() public function __construct()
{ {
$this->settings = [ $this->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' => [ 'logger' => [
'name' => Settings::getEnvironment('LOG_NAME', 'bouncer'), 'name' => Settings::getEnvironment('LOG_NAME', 'bouncer'),
'path' => Settings::getEnvironment('LOG_FILE', '/var/log/bouncer/bouncer.log'), '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), 'coloured_output' => Settings::isEnabled('LOG_COLOUR', true),
'show_state_deltas' => Settings::isEnabled('LOG_SHOW_STATE_DELTAS'), '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 public function get(string $key = '', mixed $default = null): mixed

View file

@ -5,6 +5,7 @@ declare(strict_types=1);
namespace Bouncer; namespace Bouncer;
use Bouncer\Logger\Logger; use Bouncer\Logger\Logger;
use Bouncer\Settings\Settings;
use Spatie\Emoji\Emoji; use Spatie\Emoji\Emoji;
class Target class Target
@ -16,9 +17,11 @@ class Target
private ?int $port = null; private ?int $port = null;
private bool $letsEncrypt = false; private bool $letsEncrypt = false;
private string $targetPath; private string $targetPath;
private bool $allowNonSSL = true; private bool $allowNonSSL;
private bool $useTemporaryCert = true; private bool $useTemporaryCert = false;
private bool $useGlobalCert = false; private bool $useGlobalCert = false;
private ?string $customCert = null;
private ?string $customCertKey = null;
private bool $allowWebsocketSupport = true; private bool $allowWebsocketSupport = true;
private bool $allowLargePayloads = false; private bool $allowLargePayloads = false;
private ?int $proxyTimeoutSeconds = null; private ?int $proxyTimeoutSeconds = null;
@ -28,29 +31,41 @@ class Target
private ?string $hostOverride = null; private ?string $hostOverride = null;
public function __construct( 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() 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 [ return [
'id' => $this->getId(), 'portHttp' => $this->settings->get('bouncer/http_port'),
'name' => $this->getName(), 'portHttps' => $this->settings->get('bouncer/https_port'),
'label' => $this->getLabel(), ] + [
'domains' => $this->getDomains(), 'id' => $this->getId(),
'letsEncrypt' => $this->isLetsEncrypt(), 'name' => $this->getName(),
'targetPath' => $this->getTargetPath(), 'label' => $this->getLabel(),
'useTemporaryCert' => $this->isUseTemporaryCert(), 'serverName' => $this->getNginxServerName(),
'useGlobalCert' => $this->isUseGlobalCert(), 'certType' => $this->getTypeCertInUse()->name,
'allowNonSSL' => $this->isAllowNonSSL(), 'targetPath' => $this->getTargetPath(),
'allowWebsocketSupport' => $this->isAllowWebsocketSupport(), 'customCertFile' => $this->getCustomCertPath(),
'allowLargePayloads' => $this->isAllowLargePayloads(), 'customCertKeyFile' => $this->getCustomCertKeyPath(),
'proxyTimeoutSeconds' => $this->getProxyTimeoutSeconds(), 'useCustomCert' => $this->isUseCustomCert(),
'hasAuth' => $this->hasAuth(), 'allowNonSSL' => $this->isAllowNonSSL(),
'authFile' => $this->getBasicAuthFileName(), 'allowWebsocketSupport' => $this->isAllowWebsocketSupport(),
'hasHostOverride' => $this->hasHostOverride(), 'allowLargePayloads' => $this->isAllowLargePayloads(),
'hostOverride' => $this->getHostOverride(), 'proxyTimeoutSeconds' => $this->getProxyTimeoutSeconds(),
'hasAuth' => $this->hasAuth(),
'authFile' => $this->getBasicAuthFileName(),
'hasHostOverride' => $this->hasHostOverride(),
'hostOverride' => $this->getHostOverride(),
]; ];
} }
@ -98,6 +113,35 @@ class Target
return $this; 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 public function getAuth(): array
{ {
return [ return [
@ -136,6 +180,16 @@ class Target
return "{$this->getBasicAuthFileName()}.hash"; 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 public function getBasicAuthFileData(): string
{ {
$output = shell_exec(sprintf('htpasswd -nibB -C10 %s %s', $this->getUsername(), $this->getPassword())); $output = shell_exec(sprintf('htpasswd -nibB -C10 %s %s', $this->getUsername(), $this->getPassword()));
@ -152,6 +206,8 @@ class Target
$this->getNginxConfigFileName(), $this->getNginxConfigFileName(),
$this->hasAuth() ? $this->getBasicAuthFileName() : null, $this->hasAuth() ? $this->getBasicAuthFileName() : null,
$this->hasAuth() ? $this->getBasicAuthHashFileName() : 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 public function setUseGlobalCert(bool $useGlobalCert): self
{ {
// $this->logger->critical('setUseGlobalCert: {useGlobalCert}', ['useGlobalCert' => $useGlobalCert ? 'yes' : 'no']);
$this->useGlobalCert = $useGlobalCert; $this->useGlobalCert = $useGlobalCert;
// Global cert overrides temporary certs.
if ($useGlobalCert) {
$this->setUseTemporaryCert(false);
}
return $this; 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 public function isAllowWebsocketSupport(): bool
{ {
return $this->allowWebsocketSupport; return $this->allowWebsocketSupport;
@ -240,6 +303,25 @@ class Target
return $this->domains; 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 * @param string[] $domains
*/ */
@ -304,12 +386,12 @@ class Target
return $this; 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(); return $this->label ?? $this->getName();
} }

View file

@ -1,21 +1,24 @@
server { server {
{% if allowNonSSL %} {% if allowNonSSL %}
listen 80; listen {{ portHttp }};
listen [::]:80; listen [::]:{{ portHttp }};
{% endif %} {% endif %}
listen 443 ssl; listen {{ portHttps }} ssl;
listen [::]:443 ssl; listen [::]:{{ portHttps }} ssl;
server_name {{ domains|join(' ') }}; server_name {{ serverName }};
access_log /var/log/bouncer/{{ name }}.access.log; access_log /var/log/bouncer/{{ name }}.access.log;
error_log /var/log/bouncer/{{ name }}.error.log; error_log /var/log/bouncer/{{ name }}.error.log;
{% if useTemporaryCert %} {% if certType == 'TEMPORARY_CERT' %}
ssl_certificate /certs/example.crt; ssl_certificate /certs/example.crt;
ssl_certificate_key /certs/example.key; ssl_certificate_key /certs/example.key;
{% elseif useGlobalCert %} {% elseif certType == 'GLOBAL_CERT' %}
ssl_certificate /certs/global.crt; ssl_certificate /certs/global.crt;
ssl_certificate_key /certs/global.key; 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 /etc/letsencrypt/live/{{ name }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ name }}/privkey.pem; ssl_certificate_key /etc/letsencrypt/live/{{ name }}/privkey.pem;
{% endif %} {% endif %}
@ -60,9 +63,9 @@ server {
{% if not allowNonSSL %} {% if not allowNonSSL %}
server { server {
listen 80; listen {{ portHttp }};
listen [::]:80; listen [::]:{{ portHttp }};
server_name {{ domains|join(' ') }}; server_name {{ serverName }};
return 301 https://$host$request_uri; return 301 https://$host$request_uri;
} }
{% endif %} {% endif %}