Fix loadbalancing bug, add tests
This commit is contained in:
parent
e330f3f89c
commit
e1d8a91c07
12 changed files with 315 additions and 114 deletions
.dockerignore
.github/workflows
.php-cs-fixer.phpDockerfileTodo.mddocker-compose.ymlsrc
templates
tests/testsites
3
.dockerignore
Normal file
3
.dockerignore
Normal file
|
@ -0,0 +1,3 @@
|
||||||
|
.github
|
||||||
|
.trunk
|
||||||
|
.idea
|
6
.github/workflows/build.yml
vendored
6
.github/workflows/build.yml
vendored
|
@ -15,6 +15,11 @@ concurrency:
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
tests:
|
||||||
|
name: "Tests"
|
||||||
|
uses: ./.github/workflows/tests.yml
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
build-container:
|
build-container:
|
||||||
name: Build
|
name: Build
|
||||||
uses: ./.github/workflows/docker.build.yml
|
uses: ./.github/workflows/docker.build.yml
|
||||||
|
@ -52,6 +57,7 @@ jobs:
|
||||||
- validate-container
|
- validate-container
|
||||||
- check-php
|
- check-php
|
||||||
- check-trunk
|
- check-trunk
|
||||||
|
- tests
|
||||||
uses: ./.github/workflows/docker.release.yml
|
uses: ./.github/workflows/docker.release.yml
|
||||||
secrets: inherit
|
secrets: inherit
|
||||||
permissions:
|
permissions:
|
||||||
|
|
22
.github/workflows/docker.clean.yml
vendored
22
.github/workflows/docker.clean.yml
vendored
|
@ -12,31 +12,27 @@ on:
|
||||||
types:
|
types:
|
||||||
- completed
|
- completed
|
||||||
|
|
||||||
env:
|
|
||||||
CANDIDATE_IMAGE: ghcr.io/benzine-framework/bouncer
|
|
||||||
CANDIDATE_TAG: build-${{ github.sha }}
|
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
cleanup-delete-candidate-image:
|
cleanup-delete-candidate-image:
|
||||||
name: Delete candidate image
|
name: Delete candidate image
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: docker login ghcr.io -u ${{ github.repository_owner }} -p ${{ secrets.GITHUB_TOKEN }}
|
- run: docker login ghcr.io -u ${{ github.repository_owner }} -p ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@main
|
- uses: dataaxiom/ghcr-cleanup-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
owner: ${{ github.repository_owner }}
|
owner: benzine-framework
|
||||||
repository: ${{ github.repository }}
|
repository: docker-swarm-loadbalancer
|
||||||
name: ${{ env.CANDIDATE_IMAGE }}
|
name: bouncer
|
||||||
tags: ${{ env.CANDIDATE_TAG }}
|
tags: build-${{ github.sha }}
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
cleanup-untagged-images:
|
cleanup-untagged-images:
|
||||||
name: Delete untagged images
|
name: Delete untagged images
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
steps:
|
steps:
|
||||||
- run: docker login ghcr.io -u ${{ github.repository_owner }} -p ${{ secrets.GITHUB_TOKEN }}
|
- run: docker login ghcr.io -u ${{ github.repository_owner }} -p ${{ secrets.GITHUB_TOKEN }}
|
||||||
- uses: dataaxiom/ghcr-cleanup-action@main
|
- uses: dataaxiom/ghcr-cleanup-action@v1.0.3
|
||||||
with:
|
with:
|
||||||
owner: ${{ github.repository_owner }}
|
owner: benzine-framework
|
||||||
repository: ${{ github.repository }}
|
repository: docker-swarm-loadbalancer
|
||||||
name: ${{ env.CANDIDATE_IMAGE }}
|
name: bouncer
|
||||||
token: ${{ secrets.GITHUB_TOKEN }}
|
token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
72
.github/workflows/tests.yml
vendored
Normal file
72
.github/workflows/tests.yml
vendored
Normal file
|
@ -0,0 +1,72 @@
|
||||||
|
name: "Tests"
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_call:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test-integration:
|
||||||
|
name: Integration Tests
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
- name: Start Bouncer
|
||||||
|
run: docker compose up --build -d bouncer test-box
|
||||||
|
|
||||||
|
- name: Give it a moment...
|
||||||
|
run: sleep 5
|
||||||
|
|
||||||
|
- name: No-SSL Connect to Web A
|
||||||
|
run: |
|
||||||
|
docker compose exec test-box curl -s -D - http://a.example.org > a.nossl.http
|
||||||
|
grep "HTTP/1.1 200 OK" a.nossl.http;
|
||||||
|
grep "<h1>Website A</h1>" a.nossl.http;
|
||||||
|
- name: SSL Connect to Web A
|
||||||
|
run: |
|
||||||
|
docker compose exec test-box curl -s -k -D - https://a.example.org 2>&1 > a.ssl.http;
|
||||||
|
grep "HTTP/1.1 200 OK" a.ssl.http;
|
||||||
|
grep "<h1>Website A</h1>" a.ssl.http;
|
||||||
|
|
||||||
|
- name: No-SSL Connect to Web B
|
||||||
|
run: |
|
||||||
|
docker compose exec test-box curl -s -D - http://b.example.org 2>&1 > b.nossl.http
|
||||||
|
grep "HTTP/1.1 200 OK" b.nossl.http
|
||||||
|
grep "<h1>Website B</h1>" b.nossl.http
|
||||||
|
- name: SSL Connect to Web B
|
||||||
|
run: |
|
||||||
|
docker compose exec test-box curl -s -k -D - https://b.example.org 2>&1 > b.ssl.http
|
||||||
|
grep "HTTP/1.1 200 OK" b.ssl.http
|
||||||
|
grep "<h1>Website B</h1>" b.ssl.http
|
||||||
|
|
||||||
|
- name: No-SSL Connect to SSL-redirect
|
||||||
|
run: |
|
||||||
|
docker compose exec test-box curl -s -D - http://redirect-to-ssl.example.org 2>&1 > redirect.nossl.http
|
||||||
|
# Validate its redirected
|
||||||
|
grep "HTTP/1.1 301 Moved Permanently" redirect.nossl.http
|
||||||
|
# And going to the right place
|
||||||
|
grep "Location: https://redirect-to-ssl.example.org" redirect.nossl.http
|
||||||
|
- name: SSL Connect to SSL-redirect
|
||||||
|
run: |
|
||||||
|
docker compose exec test-box curl -s -k -D - https://redirect-to-ssl.example.org 2>&1 > redirect.ssl.http
|
||||||
|
grep "HTTP/1.1 200 OK" redirect.ssl.http
|
||||||
|
grep "<h1>Website redirect-to-ssl</h1>" redirect.ssl.http
|
||||||
|
|
||||||
|
- name: Connect to Plural multiple times and verify it loadbalances
|
||||||
|
run: |
|
||||||
|
rm -f plural_requests
|
||||||
|
for i in {1..20}; do
|
||||||
|
docker compose exec test-box curl -s -k https://plural.example.org 2>&1 >> plural_requests
|
||||||
|
done
|
||||||
|
|
||||||
|
requests=$(cat plural_requests | grep "Running on" | sort | uniq | wc -l)
|
||||||
|
echo "Unique Servers: $requests"
|
||||||
|
|
||||||
|
# We should have exactly 3
|
||||||
|
test $requests -eq 3
|
||||||
|
|
||||||
|
- name: Cleanup
|
||||||
|
if: always()
|
||||||
|
run: docker compose down -v --remove-orphans
|
|
@ -1,8 +1,16 @@
|
||||||
<?php
|
<?php
|
||||||
$finder = PhpCsFixer\Finder::create();
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
use PhpCsFixer\Config;
|
||||||
|
use PhpCsFixer\Finder;
|
||||||
|
use PhpCsFixer\Runner\Parallel\ParallelConfig;
|
||||||
|
|
||||||
|
$finder = Finder::create();
|
||||||
$finder->in(__DIR__);
|
$finder->in(__DIR__);
|
||||||
|
|
||||||
return (new PhpCsFixer\Config())
|
return (new Config())
|
||||||
|
->setParallelConfig(new ParallelConfig(10, 20, 120))
|
||||||
->setRiskyAllowed(true)
|
->setRiskyAllowed(true)
|
||||||
->setHideProgress(false)
|
->setHideProgress(false)
|
||||||
->setRules([
|
->setRules([
|
||||||
|
@ -37,4 +45,4 @@ return (new PhpCsFixer\Config())
|
||||||
'native_function_invocation' => false, // Disabled as adding count($i) -> \count($i) is annoying, but supposedly more performant
|
'native_function_invocation' => false, // Disabled as adding count($i) -> \count($i) is annoying, but supposedly more performant
|
||||||
])
|
])
|
||||||
->setFinder($finder)
|
->setFinder($finder)
|
||||||
;
|
;
|
||||||
|
|
|
@ -91,11 +91,18 @@ EXPOSE 80
|
||||||
EXPOSE 443
|
EXPOSE 443
|
||||||
|
|
||||||
# Set a healthcheck to curl the bouncer and expect a 200
|
# Set a healthcheck to curl the bouncer and expect a 200
|
||||||
|
# A moderately long start period is important because while it IS serving a HTTP 200 immediately, it might not have
|
||||||
|
# completed probing the docker socket and generating the config yet.
|
||||||
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
|
||||||
|
|
||||||
# checkov:skip=CKV_DOCKER_3 This is a test container.
|
# checkov:skip=CKV_DOCKER_3 This is a test container.
|
||||||
FROM php:nginx as test-app
|
FROM php:nginx as test-app
|
||||||
COPY tests/testsites /app/public
|
COPY tests/testsites /app/public
|
||||||
HEALTHCHECK --start-period=30s \
|
HEALTHCHECK --start-period=3s --interval=3s \
|
||||||
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
|
||||||
|
|
||||||
|
# checkov:skip=CKV_DOCKER_7 This is a test container.
|
||||||
|
# checkov:skip=CKV_DOCKER_3 This is a test container.
|
||||||
|
FROM alpine as test-box
|
||||||
|
RUN apk add --no-cache curl bash
|
12
Todo.md
Normal file
12
Todo.md
Normal file
|
@ -0,0 +1,12 @@
|
||||||
|
# Todo List
|
||||||
|
|
||||||
|
- Create an actual healthcheck endpoint. It should:
|
||||||
|
- Display if the initial docker socket scan & generation is complete
|
||||||
|
- Show Domains served by the proxy behind a per-service option flag
|
||||||
|
- Domains should be grouped
|
||||||
|
- Some statistics about the domains such as the number of instances serving it should be presented
|
||||||
|
- Maybe the amount of traffic being sent to it?
|
||||||
|
- Switch to using service labels instead of envs
|
||||||
|
- Write tests to verify the S3/Lets Encrypt functionality still works
|
||||||
|
- Log traffic to somewhere useful
|
||||||
|
- Add a way to write logs to external services, exposing the Monolog behavior
|
|
@ -1,46 +1,74 @@
|
||||||
|
networks:
|
||||||
|
default:
|
||||||
|
|
||||||
services:
|
services:
|
||||||
bouncer:
|
bouncer:
|
||||||
image: ghcr.io/benzine-framework/bouncer:latest
|
|
||||||
build:
|
build:
|
||||||
|
context: .
|
||||||
target: bouncer
|
target: bouncer
|
||||||
additional_contexts:
|
additional_contexts:
|
||||||
- php:cli=docker-image://ghcr.io/benzine-framework/php:cli-8.2
|
- 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
|
- ./src:/app/src
|
||||||
# environment:
|
- ./templates:/app/templates
|
||||||
# - BOUNCER_LETSENCRYPT_MODE=staging
|
- ./vendor:/app/vendor
|
||||||
# - BOUNCER_LETSENCRYPT_EMAIL=matthew@baggett.me
|
networks:
|
||||||
# - BOUNCER_S3_ENDPOINT=http://grey.ooo:9000
|
default:
|
||||||
# - BOUNCER_S3_KEY_ID=geusebio
|
aliases:
|
||||||
# - BOUNCER_S3_KEY_SECRET=changeme
|
- a.example.org
|
||||||
# - BOUNCER_S3_BUCKET=bouncer-certificates
|
- b.example.org
|
||||||
# - BOUNCER_S3_USE_PATH_STYLE_ENDPOINT="yes"
|
- plural.example.org
|
||||||
ports:
|
- redirect-to-ssl.example.org
|
||||||
- 127.0.99.100:80:80
|
- nope.example.org
|
||||||
- 127.0.99.100:443:443
|
depends_on:
|
||||||
|
web-a:
|
||||||
web-a:
|
condition: service_healthy
|
||||||
image: test-app-a
|
web-b:
|
||||||
|
condition: service_healthy
|
||||||
|
web-redirect-ssl:
|
||||||
|
condition: service_healthy
|
||||||
|
web-plural:
|
||||||
|
condition: service_healthy
|
||||||
|
web-a: &web
|
||||||
build:
|
build:
|
||||||
target: test-app-a
|
context: .
|
||||||
|
target: test-app
|
||||||
additional_contexts:
|
additional_contexts:
|
||||||
- php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2
|
- php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2
|
||||||
volumes:
|
volumes:
|
||||||
- ./tests/testsites:/app/public
|
- ./tests/testsites:/app/public
|
||||||
environment:
|
environment:
|
||||||
- BOUNCER_DOMAIN=a.web.grey.ooo
|
- BOUNCER_DOMAIN=a.example.org
|
||||||
- BOUNCER_TARGET_PORT=80
|
- BOUNCER_TARGET_PORT=80
|
||||||
- SITE_NAME=A
|
- SITE_NAME=A
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
web-b:
|
web-b:
|
||||||
image: test-app-b
|
<<: *web
|
||||||
build:
|
|
||||||
target: test-app-b
|
|
||||||
additional_contexts:
|
|
||||||
- php:nginx=docker-image://ghcr.io/benzine-framework/php:nginx-8.2
|
|
||||||
volumes:
|
|
||||||
- ./tests/testsites:/app/public
|
|
||||||
environment:
|
environment:
|
||||||
- BOUNCER_DOMAIN=b.web.grey.ooo
|
- BOUNCER_DOMAIN=b.example.org
|
||||||
- BOUNCER_TARGET_PORT=80
|
- BOUNCER_TARGET_PORT=80
|
||||||
- SITE_NAME=B
|
- SITE_NAME=B
|
||||||
|
web-plural:
|
||||||
|
<<: *web
|
||||||
|
environment:
|
||||||
|
- BOUNCER_DOMAIN=plural.example.org
|
||||||
|
- BOUNCER_TARGET_PORT=80
|
||||||
|
- SITE_NAME=plural
|
||||||
|
deploy:
|
||||||
|
replicas: 3
|
||||||
|
web-redirect-ssl:
|
||||||
|
<<: *web
|
||||||
|
environment:
|
||||||
|
- BOUNCER_DOMAIN=redirect-to-ssl.example.org
|
||||||
|
- BOUNCER_TARGET_PORT=80
|
||||||
|
- SITE_NAME=redirect-to-ssl
|
||||||
|
- BOUNCER_ALLOW_NON_SSL=false
|
||||||
|
test-box:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
target: test-box
|
||||||
|
command: ["tail", "-f", "/dev/null"]
|
||||||
|
networks:
|
||||||
|
- default
|
||||||
|
|
131
src/Bouncer.php
131
src/Bouncer.php
|
@ -49,6 +49,7 @@ class Bouncer
|
||||||
private ?int $lastUpdateEpoch = null;
|
private ?int $lastUpdateEpoch = null;
|
||||||
private int $maximumNginxConfigCreationNotices = 15;
|
private int $maximumNginxConfigCreationNotices = 15;
|
||||||
private Settings $settings;
|
private Settings $settings;
|
||||||
|
private bool $testMode;
|
||||||
|
|
||||||
private const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
|
private const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
|
||||||
private const FILESYSTEM_CONFIG_DIR = '/etc/nginx/sites-enabled';
|
private const FILESYSTEM_CONFIG_DIR = '/etc/nginx/sites-enabled';
|
||||||
|
@ -110,6 +111,8 @@ class Bouncer
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->setTestMode(isset($this->environment['TEST_MODE']));
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getMaximumNginxConfigCreationNotices(): int
|
public function getMaximumNginxConfigCreationNotices(): int
|
||||||
|
@ -160,6 +163,21 @@ class Bouncer
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function isTestMode(): bool
|
||||||
|
{
|
||||||
|
return $this->testMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function setTestMode(bool $testMode): Bouncer
|
||||||
|
{
|
||||||
|
$this->testMode = $testMode;
|
||||||
|
if ($this->testMode) {
|
||||||
|
$this->logger->critical('Test mode is enabled. It will immediately crash out upon completion of 1 cycle.', ['emoji' => Emoji::warning()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Target[]
|
* @return Target[]
|
||||||
*
|
*
|
||||||
|
@ -206,32 +224,45 @@ class Bouncer
|
||||||
|
|
||||||
if (!empty($container['NetworkSettings']['IPAddress'])) {
|
if (!empty($container['NetworkSettings']['IPAddress'])) {
|
||||||
// As per docker service
|
// As per docker service
|
||||||
$bouncerTarget->setEndpointHostnameOrIp($container['NetworkSettings']['IPAddress']);
|
$bouncerTarget->setEndpoints([$container['NetworkSettings']['IPAddress']]);
|
||||||
} else {
|
} else {
|
||||||
// As per docker compose
|
// As per docker compose
|
||||||
$networks = array_values($container['NetworkSettings']['Networks']);
|
$networks = array_values($container['NetworkSettings']['Networks']);
|
||||||
$bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']);
|
$bouncerTarget->setEndpoints([$networks[0]['IPAddress']]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$bouncerTarget->setTargetPath(sprintf('http://%s:%d', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort() >= 0 ? $bouncerTarget->getPort() : 80));
|
|
||||||
|
|
||||||
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
|
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
|
||||||
|
|
||||||
$valid = $bouncerTarget->isEndpointValid();
|
// if this bouncerTarget already exists, merge it in instead of adding it.
|
||||||
// $this->logger->debug(sprintf(
|
foreach ($bouncerTargets as $existing) {
|
||||||
// '%s Decided that %s has the endpoint %s and it %s.',
|
if (isset($bouncerTarget) && $existing->getDomains() == $bouncerTarget->getDomains()) {
|
||||||
// Emoji::magnifyingGlassTiltedLeft(),
|
$this->logger->debug('Found another instance of the same service, merging them together.', ['emoji' => Emoji::cupcake()]);
|
||||||
// $bouncerTarget->getName(),
|
$existing->setEndpoints(array_merge($existing->getEndpoints(), $bouncerTarget->getEndpoints()));
|
||||||
// $bouncerTarget->getEndpointHostnameOrIp(),
|
unset($bouncerTarget);
|
||||||
// $valid ? 'is valid' : 'is not valid'
|
}
|
||||||
// ));
|
}
|
||||||
if ($valid) {
|
if (isset($bouncerTarget)) {
|
||||||
$bouncerTargets[] = $bouncerTarget;
|
$bouncerTargets[] = $bouncerTarget;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $bouncerTargets;
|
$validBouncerTargets = [];
|
||||||
|
foreach ($bouncerTargets as $bouncerTarget) {
|
||||||
|
$valid = $bouncerTarget->isEndpointValid();
|
||||||
|
// $this->logger->debug(sprintf(
|
||||||
|
// '%s Decided that %s has the endpoint %s and it %s.',
|
||||||
|
// Emoji::magnifyingGlassTiltedLeft(),
|
||||||
|
// $bouncerTarget->getName(),
|
||||||
|
// $bouncerTarget->getEndpointHostnameOrIp(),
|
||||||
|
// $valid ? 'is valid' : 'is not valid'
|
||||||
|
// ));
|
||||||
|
if ($valid) {
|
||||||
|
$validBouncerTargets[] = $bouncerTarget;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validBouncerTargets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function findContainersSwarmMode(): array
|
public function findContainersSwarmMode(): array
|
||||||
|
@ -288,10 +319,10 @@ class Bouncer
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if ($bouncerTarget->isPortSet()) {
|
if ($bouncerTarget->isPortSet()) {
|
||||||
$bouncerTarget->setEndpointHostnameOrIp($service['Spec']['Name']);
|
$bouncerTarget->setEndpoints([$service['Spec']['Name']]);
|
||||||
// $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()]);
|
// $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->setEndpoints(['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('{label}: ports block missing for {target_name}. Try setting BOUNCER_TARGET_PORT.', ['emoji' => Emoji::warning() . ' Bouncer.php', 'label' => $bouncerTarget->getLabel(), 'target_name' => $bouncerTarget->getName()]);
|
$this->logger->warning('{label}: ports block missing for {target_name}. Try setting BOUNCER_TARGET_PORT.', ['emoji' => Emoji::warning() . ' Bouncer.php', 'label' => $bouncerTarget->getLabel(), 'target_name' => $bouncerTarget->getName()]);
|
||||||
|
@ -303,28 +334,39 @@ class Bouncer
|
||||||
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
$bouncerTarget->setTargetPath(sprintf('http://%s:%d', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort()));
|
|
||||||
|
|
||||||
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
|
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
|
||||||
|
|
||||||
// @phpstan-ignore-next-line MB: I'm not sure you're right about ->hasCustomNginxConfig only returning false, Stan..
|
// if this bouncerTarget already exists, merge it in instead of adding it.
|
||||||
if ($bouncerTarget->isEndpointValid() || $bouncerTarget->hasCustomNginxConfig()) {
|
foreach ($bouncerTargets as $existing) {
|
||||||
$bouncerTargets[] = $bouncerTarget;
|
if ($existing->getDomains() == $bouncerTarget->getDomains()) {
|
||||||
} else {
|
$this->logger->debug('Found another instance of the same service, merging them together.', ['emoji' => Emoji::cupcake()]);
|
||||||
$this->logger->debug(
|
$existing->setEndpoints(array_merge($existing->getEndpoints(), $bouncerTarget->getEndpoints()));
|
||||||
'Decided that {target_name} has the endpoint {endpoint} and it is not valid.',
|
unset($bouncerTarget);
|
||||||
[
|
}
|
||||||
'emoji' => Emoji::magnifyingGlassTiltedLeft(),
|
|
||||||
'target_name' => $bouncerTarget->getName(),
|
|
||||||
'endpoint' => $bouncerTarget->getEndpointHostnameOrIp(),
|
|
||||||
]
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return $bouncerTargets;
|
// Iterate over bouncers and check validity
|
||||||
|
$validBouncerTargets = [];
|
||||||
|
foreach ($bouncerTargets as $bouncerTarget) {
|
||||||
|
// @phpstan-ignore-next-line MB: I'm not sure you're right about ->hasCustomNginxConfig only returning false, Stan..
|
||||||
|
if ($bouncerTarget->isEndpointValid() || $bouncerTarget->hasCustomNginxConfig()) {
|
||||||
|
$validBouncerTargets[] = $bouncerTarget;
|
||||||
|
} else {
|
||||||
|
$this->logger->debug(
|
||||||
|
'Decided that {target_name} has the endpoint {endpoint} and it is not valid.',
|
||||||
|
[
|
||||||
|
'emoji' => Emoji::magnifyingGlassTiltedLeft(),
|
||||||
|
'target_name' => $bouncerTarget->getName(),
|
||||||
|
'endpoint' => $bouncerTarget->getEndpoints()[0],
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $validBouncerTargets;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function run(): void
|
public function run(): void
|
||||||
|
@ -512,10 +554,7 @@ class Bouncer
|
||||||
} 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->previousSwarmState === []) {
|
||||||
$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;
|
$isTainted = true;
|
||||||
}
|
}
|
||||||
|
@ -663,6 +702,13 @@ class Bouncer
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ($this->isTestMode()) {
|
||||||
|
$this->logger->info('Test mode enabled, not restarting nginx. Infact, I\'ll die now..', ['emoji' => Emoji::warning() . ' Bouncer.php']);
|
||||||
|
$this->dumpConfigs();
|
||||||
|
|
||||||
|
exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
// Wait for next change
|
// Wait for next change
|
||||||
$this->waitUntilContainerChange();
|
$this->waitUntilContainerChange();
|
||||||
}
|
}
|
||||||
|
@ -924,7 +970,6 @@ class Bouncer
|
||||||
$target->setUseTemporaryCert(false);
|
$target->setUseTemporaryCert(false);
|
||||||
$this->generateNginxConfig($target);
|
$this->generateNginxConfig($target);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->restartNginx();
|
$this->restartNginx();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -937,4 +982,18 @@ class Bouncer
|
||||||
$nginxRestartOutput = $shell->run($command);
|
$nginxRestartOutput = $shell->run($command);
|
||||||
$this->logger->debug('Nginx restarted {restart_output}', ['restart_output' => $nginxRestartOutput, 'emoji' => Emoji::partyPopper()]);
|
$this->logger->debug('Nginx restarted {restart_output}', ['restart_output' => $nginxRestartOutput, 'emoji' => Emoji::partyPopper()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private function dumpConfigs(): void
|
||||||
|
{
|
||||||
|
// Dump the contents of every .conf file in /etc/nginx/sites-enabled
|
||||||
|
foreach ($this->configFilesystem->listContents('') as $file) {
|
||||||
|
if ($file['type'] == 'file' && pathinfo($file['path'], PATHINFO_EXTENSION) == 'conf') {
|
||||||
|
if ($file['path'] == 'default.conf') {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$this->logger->info('Dumping {file}', ['emoji' => Emoji::pencil() . ' Bouncer.php', 'file' => $file['path']]);
|
||||||
|
echo $this->configFilesystem->read($file['path']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -14,10 +14,9 @@ class Target
|
||||||
private string $id;
|
private string $id;
|
||||||
private ?string $label = null;
|
private ?string $label = null;
|
||||||
private array $domains;
|
private array $domains;
|
||||||
private string $endpointHostnameOrIp;
|
private array $endpoints = [];
|
||||||
private ?int $port = null;
|
private ?int $port = null;
|
||||||
private bool $letsEncrypt = false;
|
private bool $letsEncrypt = false;
|
||||||
private string $targetPath;
|
|
||||||
private bool $allowNonSSL;
|
private bool $allowNonSSL;
|
||||||
private bool $useTemporaryCert = false;
|
private bool $useTemporaryCert = false;
|
||||||
private bool $useGlobalCert = false;
|
private bool $useGlobalCert = false;
|
||||||
|
@ -55,8 +54,9 @@ class Target
|
||||||
'name' => $this->getName(),
|
'name' => $this->getName(),
|
||||||
'label' => $this->getLabel(),
|
'label' => $this->getLabel(),
|
||||||
'serverName' => $this->getNginxServerName(),
|
'serverName' => $this->getNginxServerName(),
|
||||||
|
'backends' => $this->getBackends(),
|
||||||
|
'backendName' => $this->getBackendName(),
|
||||||
'certType' => $this->getTypeCertInUse()->name,
|
'certType' => $this->getTypeCertInUse()->name,
|
||||||
'targetPath' => $this->getTargetPath(),
|
|
||||||
'customCertFile' => $this->getCustomCertPath(),
|
'customCertFile' => $this->getCustomCertPath(),
|
||||||
'customCertKeyFile' => $this->getCustomCertKeyPath(),
|
'customCertKeyFile' => $this->getCustomCertKeyPath(),
|
||||||
'useCustomCert' => $this->isUseCustomCert(),
|
'useCustomCert' => $this->isUseCustomCert(),
|
||||||
|
@ -318,6 +318,11 @@ class Target
|
||||||
return implode(' ', $this->getNginxServerNames());
|
return implode(' ', $this->getNginxServerNames());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function getBackendName()
|
||||||
|
{
|
||||||
|
return sprintf('backend_%s', substr(md5($this->getName()), 0, 8));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string[] $domains
|
* @param string[] $domains
|
||||||
*/
|
*/
|
||||||
|
@ -341,26 +346,24 @@ class Target
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getTargetPath(): string
|
public function getBackends(): array
|
||||||
{
|
{
|
||||||
return $this->targetPath;
|
$backends = [];
|
||||||
|
foreach ($this->getEndpoints() as $endpoint) {
|
||||||
|
$backends[] = sprintf('%s:%d', $endpoint, $this->getPort());
|
||||||
|
}
|
||||||
|
|
||||||
|
return $backends;
|
||||||
}
|
}
|
||||||
|
|
||||||
public function setTargetPath(string $targetPath): self
|
public function getEndpoints(): array
|
||||||
{
|
{
|
||||||
$this->targetPath = $targetPath;
|
return $this->endpoints;
|
||||||
|
|
||||||
return $this;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public function getEndpointHostnameOrIp(): string
|
public function setEndpoints(array $endpoints): self
|
||||||
{
|
{
|
||||||
return $this->endpointHostnameOrIp;
|
$this->endpoints = $endpoints;
|
||||||
}
|
|
||||||
|
|
||||||
public function setEndpointHostnameOrIp(string $endpointHostnameOrIp): self
|
|
||||||
{
|
|
||||||
$this->endpointHostnameOrIp = $endpointHostnameOrIp;
|
|
||||||
|
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
@ -422,24 +425,25 @@ class Target
|
||||||
|
|
||||||
public function isEndpointValid(): bool
|
public function isEndpointValid(): bool
|
||||||
{
|
{
|
||||||
// Is it just an IP?
|
foreach ($this->getEndpoints() as $endpoint) {
|
||||||
if (filter_var($this->getEndpointHostnameOrIp(), FILTER_VALIDATE_IP)) {
|
// Is it just an IP?
|
||||||
// $this->logger->debug(sprintf('%s isEndpointValid: %s is a normal IP', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp()));
|
if (filter_var($endpoint, FILTER_VALIDATE_IP)) {
|
||||||
|
// $this->logger->debug(sprintf('%s isEndpointValid: %s is a normal IP', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp()));
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Is it a Hostname that resolves?
|
||||||
|
$resolved = gethostbyname($endpoint);
|
||||||
|
if (filter_var($resolved, FILTER_VALIDATE_IP)) {
|
||||||
|
// $this->logger->critical(sprintf('%s isEndpointValid: %s is a hostname that resolves to a normal IP %s', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp(), $resolved));
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
$this->logger->critical('isEndpointValid: {endpoint} is a hostname that does not resolve', ['emoji' => Emoji::magnifyingGlassTiltedRight(), 'endpoint' => $endpoint]);
|
||||||
|
$this->setRequiresForcedScanning(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Is it a Hostname that resolves?
|
|
||||||
$resolved = gethostbyname($this->getEndpointHostnameOrIp());
|
|
||||||
if (filter_var($resolved, FILTER_VALIDATE_IP)) {
|
|
||||||
// $this->logger->critical(sprintf('%s isEndpointValid: %s is a hostname that resolves to a normal IP %s', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp(), $resolved));
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->logger->critical('isEndpointValid: {endpoint} is a hostname that does not resolve', ['emoji' => Emoji::magnifyingGlassTiltedRight(), 'endpoint' => $this->getEndpointHostnameOrIp()]);
|
|
||||||
$this->setRequiresForcedScanning(true);
|
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -1,3 +1,9 @@
|
||||||
|
upstream {{ backendName }} {
|
||||||
|
least_conn;
|
||||||
|
{% for backend in backends %}
|
||||||
|
server {{ backend }};
|
||||||
|
{% endfor %}
|
||||||
|
}
|
||||||
server {
|
server {
|
||||||
{% if allowNonSSL %}
|
{% if allowNonSSL %}
|
||||||
# Non-SSL Traffic is allowed
|
# Non-SSL Traffic is allowed
|
||||||
|
@ -39,7 +45,7 @@ server {
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
# Server to send the request on to
|
# Server to send the request on to
|
||||||
proxy_pass "{{ targetPath }}";
|
proxy_pass "http://{{ backendName }}";
|
||||||
|
|
||||||
# Standard headers setting origin data
|
# Standard headers setting origin data
|
||||||
proxy_set_header X-Real-IP $remote_addr;
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
|
|
@ -2,6 +2,6 @@
|
||||||
|
|
||||||
declare(strict_types=1);
|
declare(strict_types=1);
|
||||||
$environment = array_merge($_ENV, $_SERVER);
|
$environment = array_merge($_ENV, $_SERVER);
|
||||||
$site = $environment['SITE_NAME'] ?? 'unknown';
|
$site = $environment['SITE_NAME'] ?? 'unknown';
|
||||||
$server = $environment['SERVER_NAME'] ?? gethostname();
|
$server = $environment['HOSTNAME'] ?? gethostname();
|
||||||
printf('<h1>Website %s</h1><p>Running on %s</p>', $site, $server);
|
printf("<h1>Website %s</h1>\n<p>Running on %s</p>\n", $site, $server);
|
||||||
|
|
Loading…
Reference in a new issue