Feature/bouncer (#7)
* Initial work * Setup script stub * Wrap runit and call dos2unix beforehand. * Working to the point I need to make it sync in and out of s3. * Seems like we're done and its working. * Add build process. * Add build process. * Bugfixes discovered during deployment. * Copy certs into /live because certbot is a pain. * More elegant about hammering letsencrypt. * Working!
This commit is contained in:
parent
d820562de7
commit
2fd5c62074
26 changed files with 4086 additions and 2 deletions
.github/workflows
bouncer
.dockerignore.gitignore.php-cs-fixer.phpDockerfileNginxDefaultNginxSSLNginxTemplate.twigbouncerbouncer.runitcomposer.jsoncomposer.lockdocker-compose.ymlgrey-ooo-test.ymllogs-nginx-access.runitlogs-nginx-error.runitlogs.runitnginx.runit
public
self-signed-certificates
test
marshall
34
.github/workflows/bouncer.yml
vendored
Normal file
34
.github/workflows/bouncer.yml
vendored
Normal file
|
@ -0,0 +1,34 @@
|
|||
name: Build Nginx + LetsEncrypt Bouncer
|
||||
|
||||
on:
|
||||
push:
|
||||
paths:
|
||||
- bouncer
|
||||
workflow_run:
|
||||
workflows:
|
||||
- Build PHP Flavours
|
||||
branches: [ 'master', 'feature/**' ]
|
||||
types:
|
||||
- completed
|
||||
|
||||
jobs:
|
||||
bouncer-build:
|
||||
name: "Bake Bouncer Container"
|
||||
runs-on: self-hosted
|
||||
steps:
|
||||
- uses: actions/checkout@v1
|
||||
- uses: docker/setup-qemu-action@v1
|
||||
- uses: docker/setup-buildx-action@v1
|
||||
- uses: docker/login-action@v1
|
||||
name: Login to Docker Hub
|
||||
with:
|
||||
username: ${{ secrets.DOCKER_HUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
|
||||
- uses: docker/build-push-action@v2
|
||||
name: Build & Push
|
||||
with:
|
||||
context: bouncer
|
||||
platforms: linux/amd64,linux/arm64
|
||||
pull: true
|
||||
push: true
|
||||
tags: benzine/bouncer
|
1
bouncer/.dockerignore
Normal file
1
bouncer/.dockerignore
Normal file
|
@ -0,0 +1 @@
|
|||
vendor
|
3
bouncer/.gitignore
vendored
Normal file
3
bouncer/.gitignore
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
/docker-compose.override.yml
|
||||
/vendor
|
||||
/.php-cs-fixer.cache
|
22
bouncer/.php-cs-fixer.php
Normal file
22
bouncer/.php-cs-fixer.php
Normal file
|
@ -0,0 +1,22 @@
|
|||
<?php
|
||||
$finder = PhpCsFixer\Finder::create();
|
||||
$finder->in(__DIR__);
|
||||
|
||||
return (new PhpCsFixer\Config)
|
||||
->setRiskyAllowed(true)
|
||||
->setHideProgress(false)
|
||||
->setRules([
|
||||
'@PSR2' => true,
|
||||
'strict_param' => true,
|
||||
'array_syntax' => ['syntax' => 'short'],
|
||||
'@PhpCsFixer' => true,
|
||||
'@PHP73Migration' => true,
|
||||
'no_php4_constructor' => true,
|
||||
'no_unused_imports' => true,
|
||||
'no_useless_else' => true,
|
||||
'no_superfluous_phpdoc_tags' => true,
|
||||
'void_return' => true,
|
||||
'yoda_style' => false,
|
||||
])
|
||||
->setFinder($finder)
|
||||
;
|
48
bouncer/Dockerfile
Normal file
48
bouncer/Dockerfile
Normal file
|
@ -0,0 +1,48 @@
|
|||
FROM benzine/php:cli-8.0
|
||||
LABEL maintainer="Matthew Baggett <matthew@baggett.me>" \
|
||||
org.label-schema.vcs-url="https://github.com/benzine-framework/docker"
|
||||
COPY self-signed-certificates /certs
|
||||
|
||||
# Install nginx, certbot
|
||||
RUN apt-get -qq update && \
|
||||
# Install pre-dependencies to use apt-key.
|
||||
apt-get -yqq install --no-install-recommends \
|
||||
lsb-core \
|
||||
gnupg \
|
||||
&& \
|
||||
# Add nginx ppa
|
||||
sh -c 'echo "deb http://ppa.launchpad.net/nginx/stable/ubuntu $(lsb_release -sc) main" \
|
||||
> /etc/apt/sources.list.d/nginx-stable.list' && \
|
||||
# Add nginx key
|
||||
apt-key adv --keyserver keyserver.ubuntu.com --recv-keys C300EE8C && \
|
||||
apt-get -qq update && \
|
||||
apt-get -yqq install --no-install-recommends \
|
||||
nginx \
|
||||
python-certbot-nginx \
|
||||
&& \
|
||||
apt-get remove -yqq \
|
||||
lsb-core \
|
||||
cups-common \
|
||||
&& \
|
||||
apt-get autoremove -yqq && \
|
||||
apt-get clean && \
|
||||
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /var/lib/dpkg/status.old /var/cache/debconf/templates.dat /var/log/dpkg.log /var/log/lastlog /var/log/apt/*.log
|
||||
|
||||
VOLUME /etc/letsencrypt
|
||||
|
||||
COPY nginx.runit /etc/service/nginx/run
|
||||
COPY logs.runit /etc/service/nginx-logs/run
|
||||
COPY bouncer.runit /etc/service/bouncer/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
|
||||
COPY NginxDefault /etc/nginx/sites-enabled/default
|
||||
COPY NginxSSL /etc/nginx/sites-enabled/default-ssl
|
||||
COPY NginxTemplate.twig /app/
|
||||
# Disable daemonising in nginx
|
||||
RUN sed -i '1s;^;daemon off\;\n;' /etc/nginx/nginx.conf
|
||||
COPY bouncer /app
|
||||
COPY composer.* /app/
|
||||
RUN composer install && \
|
||||
chmod +x /app/bouncer && \
|
||||
mkdir -p /var/log/bouncer
|
25
bouncer/NginxDefault
Normal file
25
bouncer/NginxDefault
Normal file
|
@ -0,0 +1,25 @@
|
|||
server {
|
||||
listen 80 default_server;
|
||||
listen [::]:80 default_server;
|
||||
|
||||
client_max_body_size 1024M;
|
||||
|
||||
root /app/public;
|
||||
|
||||
server_name _;
|
||||
|
||||
index index.html index.htm;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file, then
|
||||
# as directory, then fall back to displaying a 404.
|
||||
try_files $uri $uri/;
|
||||
}
|
||||
|
||||
# deny access to .htaccess files, if Apache's document root
|
||||
# concurs with nginx's one
|
||||
#
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
}
|
30
bouncer/NginxSSL
Normal file
30
bouncer/NginxSSL
Normal file
|
@ -0,0 +1,30 @@
|
|||
server {
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
|
||||
client_max_body_size 1024M;
|
||||
|
||||
root /app/public;
|
||||
|
||||
server_name _;
|
||||
|
||||
index index.html index.htm;
|
||||
|
||||
ssl_certificate /certs/example.crt;
|
||||
ssl_certificate_key /certs/example.key;
|
||||
# ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
# First attempt to serve request as file, then
|
||||
# as directory, then fall back to displaying a 404.
|
||||
try_files $uri $uri/;
|
||||
}
|
||||
|
||||
# deny access to .htaccess files, if Apache's document root
|
||||
# concurs with nginx's one
|
||||
#
|
||||
location ~ /\.ht {
|
||||
deny all;
|
||||
}
|
||||
}
|
33
bouncer/NginxTemplate.twig
Normal file
33
bouncer/NginxTemplate.twig
Normal file
|
@ -0,0 +1,33 @@
|
|||
server {
|
||||
{% if allowNonSSL %}
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
{% endif %}
|
||||
listen 443 ssl;
|
||||
listen [::]:443 ssl;
|
||||
server_name {{ domains|join(' ') }};
|
||||
access_log /var/log/bouncer/{{ name }}.access.log;
|
||||
error_log /var/log/bouncer/{{ name }}.error.log;
|
||||
|
||||
{% if useTemporaryCert %}
|
||||
ssl_certificate /certs/example.crt;
|
||||
ssl_certificate_key /certs/example.key;
|
||||
{% else %}
|
||||
ssl_certificate /etc/letsencrypt/live/{{ name }}/fullchain.pem;
|
||||
ssl_certificate_key /etc/letsencrypt/live/{{ name }}/privkey.pem;
|
||||
{% endif %}
|
||||
# ssl_protocols TLSv1 TLSv1.1 TLSv1.2;
|
||||
# ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
|
||||
location / {
|
||||
proxy_pass {{ targetPath }};
|
||||
}
|
||||
}
|
||||
{% if not allowNonSSL %}
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name {{ domains|join(' ') }};
|
||||
return 301 https://$host$request_uri;
|
||||
}
|
||||
{% endif %}
|
488
bouncer/bouncer
Executable file
488
bouncer/bouncer
Executable file
|
@ -0,0 +1,488 @@
|
|||
#!/usr/bin/env php
|
||||
<?php
|
||||
require_once 'vendor/autoload.php';
|
||||
|
||||
use AdamBrett\ShellWrapper\Command\Builder as CommandBuilder;
|
||||
use AdamBrett\ShellWrapper\Runners\Exec;
|
||||
use Aws\S3\S3Client;
|
||||
use Bramus\Monolog\Formatter\ColoredLineFormatter;
|
||||
use GuzzleHttp\Client as Guzzle;
|
||||
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
|
||||
use League\Flysystem\FileAttributes;
|
||||
use League\Flysystem\Filesystem;
|
||||
use League\Flysystem\Local\LocalFilesystemAdapter;
|
||||
use Monolog\Handler\StreamHandler;
|
||||
use Monolog\Logger;
|
||||
use Spatie\Emoji\Emoji;
|
||||
use Twig\Environment;
|
||||
use Twig\Loader\FilesystemLoader;
|
||||
|
||||
class BouncerTarget
|
||||
{
|
||||
private string $id;
|
||||
private array $domains;
|
||||
private string $ip;
|
||||
private int $port = 80;
|
||||
private bool $letsEncrypt = false;
|
||||
private string $targetPath;
|
||||
private bool $allowNonSSL = false;
|
||||
|
||||
private bool $useTemporaryCert = true;
|
||||
|
||||
public function __toArray()
|
||||
{
|
||||
return [
|
||||
'id' => $this->getId(),
|
||||
'name' => $this->getName(),
|
||||
'domains' => $this->getDomains(),
|
||||
'letsEncrypt' => $this->isLetsEncrypt(),
|
||||
'targetPath' => $this->getTargetPath(),
|
||||
'useTemporaryCert' => $this->isUseTemporaryCert(),
|
||||
'allowNonSSL' => $this->isAllowNonSSL(),
|
||||
];
|
||||
}
|
||||
|
||||
public function isUseTemporaryCert(): bool
|
||||
{
|
||||
return $this->useTemporaryCert;
|
||||
}
|
||||
|
||||
public function setUseTemporaryCert(bool $useTemporaryCert): BouncerTarget
|
||||
{
|
||||
$this->useTemporaryCert = $useTemporaryCert;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getId(): string
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
public function setId(string $id): BouncerTarget
|
||||
{
|
||||
$this->id = $id;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getDomains(): array
|
||||
{
|
||||
return $this->domains;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $domains
|
||||
*/
|
||||
public function setDomains(array $domains): BouncerTarget
|
||||
{
|
||||
$this->domains = $domains;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function isLetsEncrypt(): bool
|
||||
{
|
||||
return $this->letsEncrypt;
|
||||
}
|
||||
|
||||
public function setLetsEncrypt(bool $letsEncrypt): BouncerTarget
|
||||
{
|
||||
$this->letsEncrypt = $letsEncrypt;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getTargetPath(): string
|
||||
{
|
||||
return $this->targetPath;
|
||||
}
|
||||
|
||||
public function setTargetPath(string $targetPath): BouncerTarget
|
||||
{
|
||||
$this->targetPath = $targetPath;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getIp(): string
|
||||
{
|
||||
return $this->ip;
|
||||
}
|
||||
|
||||
public function setIp(string $ip): BouncerTarget
|
||||
{
|
||||
$this->ip = $ip;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getPort(): int
|
||||
{
|
||||
return $this->port;
|
||||
}
|
||||
|
||||
public function setPort(int $port): BouncerTarget
|
||||
{
|
||||
$this->port = $port;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
public function getName()
|
||||
{
|
||||
return reset($this->domains);
|
||||
}
|
||||
|
||||
public function isAllowNonSSL(): bool
|
||||
{
|
||||
return $this->allowNonSSL;
|
||||
}
|
||||
|
||||
public function setAllowNonSSL(bool $allowNonSSL): BouncerTarget
|
||||
{
|
||||
$this->allowNonSSL = $allowNonSSL;
|
||||
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
|
||||
class Bouncer
|
||||
{
|
||||
private array $environment;
|
||||
private Guzzle $client;
|
||||
private FilesystemLoader $loader;
|
||||
private Environment $twig;
|
||||
private Filesystem $configFilesystem;
|
||||
private Filesystem $certificateStoreLocal;
|
||||
private ?Filesystem $certificateStoreRemote;
|
||||
private Logger $logger;
|
||||
private string $instanceStateHash = '';
|
||||
private array $fileHashes;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->environment = array_merge($_ENV, $_SERVER);
|
||||
ksort($this->environment);
|
||||
|
||||
$this->logger = new Monolog\Logger('bouncer');
|
||||
$this->logger->pushHandler(new StreamHandler('/var/log/bouncer.log', Logger::DEBUG));
|
||||
$stdout = new StreamHandler('php://stdout', Logger::DEBUG);
|
||||
$stdout->setFormatter(new ColoredLineFormatter(null, "%level_name%: %message% \n"));
|
||||
$this->logger->pushHandler($stdout);
|
||||
|
||||
$this->client = new Guzzle(
|
||||
[
|
||||
'base_uri' => 'http://localhost',
|
||||
'curl' => [
|
||||
CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock',
|
||||
],
|
||||
]
|
||||
);
|
||||
|
||||
$this->loader = new FilesystemLoader([
|
||||
__DIR__,
|
||||
]);
|
||||
$this->twig = new Environment($this->loader);
|
||||
|
||||
// Set up Filesystem for sites-enabled path
|
||||
$this->configFilesystem = new Filesystem(new LocalFilesystemAdapter('/etc/nginx/sites-enabled'));
|
||||
|
||||
// Set up Local certificate store
|
||||
$this->certificateStoreLocal = new Filesystem(new LocalFilesystemAdapter('/etc/letsencrypt'));
|
||||
|
||||
// Set up Remote certificate store, if configured
|
||||
if ($this->environment['BOUNCER_S3_BUCKET']) {
|
||||
$this->certificateStoreRemote = new Filesystem(
|
||||
new AwsS3V3Adapter(
|
||||
new S3Client([
|
||||
'endpoint' => $this->environment['BOUNCER_S3_ENDPOINT'],
|
||||
'use_path_style_endpoint' => isset($this->environment['BOUNCER_S3_USE_PATH_STYLE_ENDPOINT']),
|
||||
'credentials' => [
|
||||
'key' => $this->environment['BOUNCER_S3_KEY_ID'],
|
||||
'secret' => $this->environment['BOUNCER_S3_KEY_SECRET'],
|
||||
],
|
||||
'region' => $this->environment['BOUNCER_S3_REGION'] ?? 'us-east',
|
||||
'version' => 'latest',
|
||||
]),
|
||||
$this->environment['BOUNCER_S3_BUCKET'],
|
||||
$this->environment['BOUNCER_S3_PREFIX'] ?? ''
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
*
|
||||
* @return BouncerTarget[]
|
||||
*/
|
||||
public function findContainers(): array
|
||||
{
|
||||
$bouncerTargets = [];
|
||||
$containers = json_decode($this->client->request('GET', 'containers/json')->getBody()->getContents(), true);
|
||||
foreach ($containers as $container) {
|
||||
$envs = [];
|
||||
$inspect = json_decode($this->client->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[$envKey] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isset($envs['BOUNCER_DOMAIN'])) {
|
||||
$bouncerTarget = (new BouncerTarget())
|
||||
->setId($inspect['Id'])
|
||||
;
|
||||
foreach ($envs as $eKey => $eVal) {
|
||||
switch ($eKey) {
|
||||
case 'BOUNCER_DOMAIN':
|
||||
$domains = explode(',', $eVal);
|
||||
array_walk($domains, function (&$domain, $key): void { $domain = trim($domain); });
|
||||
$bouncerTarget->setDomains($domains);
|
||||
|
||||
break;
|
||||
|
||||
case 'BOUNCER_LETSENCRYPT':
|
||||
$bouncerTarget->setLetsEncrypt(in_array(strtolower($eVal), ['yes', 'true'], true));
|
||||
|
||||
break;
|
||||
|
||||
case 'BOUNCER_TARGET_PORT':
|
||||
$bouncerTarget->setPort($eVal);
|
||||
|
||||
break;
|
||||
|
||||
case 'BOUNCER_ALLOW_NON_SSL':
|
||||
$bouncerTarget->setAllowNonSSL(in_array(strtolower($eVal), ['yes', 'true'], true));
|
||||
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (isset($inspect['NetworkSettings']['IPAddress']) && !empty($inspect['NetworkSettings']['IPAddress'])) {
|
||||
// As per docker service
|
||||
$bouncerTarget->setIp($inspect['NetworkSettings']['IPAddress']);
|
||||
} else {
|
||||
// As per docker compose
|
||||
$networks = array_values($inspect['NetworkSettings']['Networks']);
|
||||
$bouncerTarget->setIp($networks[0]['IPAddress']);
|
||||
}
|
||||
$this->logger->debug(sprintf('Decided that %s has the ip %s', $bouncerTarget->getName(), $bouncerTarget->getIp()));
|
||||
|
||||
$bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getIp(), $bouncerTarget->getPort()));
|
||||
|
||||
$bouncerTargets[] = $bouncerTarget;
|
||||
}
|
||||
}
|
||||
|
||||
return $bouncerTargets;
|
||||
}
|
||||
|
||||
public function run(): void
|
||||
{
|
||||
$this->logger->info(sprintf('%s Starting Bouncer...', Emoji::CHARACTER_TIMER_CLOCK));
|
||||
$this->stateHasChanged();
|
||||
while (true) {
|
||||
$this->runLoop();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true when something has changed.
|
||||
*
|
||||
* @throws \GuzzleHttp\Exception\GuzzleException
|
||||
*/
|
||||
private function stateHasChanged(): bool
|
||||
{
|
||||
$newInstanceStates = [];
|
||||
$containers = json_decode($this->client->request('GET', 'containers/json')->getBody()->getContents(), true);
|
||||
foreach ($containers as $container) {
|
||||
$inspect = json_decode($this->client->request('GET', "containers/{$container['Id']}/json")->getBody()->getContents(), true);
|
||||
$newInstanceStates[$inspect['Id']] = implode('::', [
|
||||
$inspect['Name'],
|
||||
$inspect['Created'],
|
||||
$inspect['Image'],
|
||||
$inspect['State']['Status'],
|
||||
sha1(implode('|', $inspect['Config']['Env'])),
|
||||
]);
|
||||
}
|
||||
$newStateHash = sha1(implode("\n", $newInstanceStates));
|
||||
//$this->logger->debug(sprintf("Old state = %s. New State = %s.", substr($this->instanceStateHash,0,7), substr($newStateHash, 0,7)));
|
||||
if ($this->instanceStateHash != $newStateHash) {
|
||||
$this->instanceStateHash = $newStateHash;
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function runLoop(): void
|
||||
{
|
||||
if ($this->s3Enabled()) {
|
||||
$this->getCertificatesFromS3();
|
||||
}
|
||||
$targets = $this->findContainers();
|
||||
$this->logger->info(sprintf('%s Found %d services with BOUNCER_DOMAIN set', Emoji::CHARACTER_MAGNIFYING_GLASS_TILTED_LEFT, count($targets)));
|
||||
foreach ($targets as $target) {
|
||||
$this->generateNginxConfig($target);
|
||||
}
|
||||
$this->generateLetsEncryptCerts($targets);
|
||||
if ($this->s3Enabled()) {
|
||||
$this->writeCertificatesToS3();
|
||||
}
|
||||
$this->waitUntilContainerChange();
|
||||
}
|
||||
|
||||
private function waitUntilContainerChange(): void
|
||||
{
|
||||
while ($this->stateHasChanged() === false) {
|
||||
sleep(5);
|
||||
}
|
||||
$this->logger->debug(sprintf('%s Host Container state has changed', Emoji::CHARACTER_WARNING));
|
||||
}
|
||||
|
||||
private function s3Enabled(): bool
|
||||
{
|
||||
return $this->certificateStoreRemote instanceof Filesystem;
|
||||
}
|
||||
|
||||
private function getCertificatesFromS3(): void
|
||||
{
|
||||
$this->logger->info(sprintf('%s Downloading Certificates from S3', Emoji::CHARACTER_DOWN_ARROW));
|
||||
foreach ($this->certificateStoreRemote->listContents('/', true) as $file) {
|
||||
/** @var FileAttributes $file */
|
||||
if ($file->isFile()) {
|
||||
$localPath = "archive/{$file->path()}";
|
||||
#$this->logger->debug(sprintf(" > Downloading {$file->path()} "));
|
||||
$this->certificateStoreLocal->writeStream($localPath, $this->certificateStoreRemote->readStream($file->path()));
|
||||
$this->fileHashes[$localPath] = sha1($this->certificateStoreLocal->read($localPath));
|
||||
}
|
||||
}
|
||||
|
||||
// Copy certs into /live because certbot is a pain.
|
||||
foreach ($this->certificateStoreLocal->listContents('/archive', true) as $newLocalCert) {
|
||||
/** @var FileAttributes $newLocalCert */
|
||||
if ($newLocalCert->isFile() && pathinfo($newLocalCert->path(), PATHINFO_EXTENSION) == 'pem') {
|
||||
$livePath = str_replace('archive/', 'live/', $newLocalCert->path());
|
||||
$this->certificateStoreLocal->writeStream($livePath, $this->certificateStoreLocal->readStream($newLocalCert->path()));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function fileChanged(string $localPath)
|
||||
{
|
||||
if (!isset($this->fileHashes[$localPath])) {
|
||||
return true;
|
||||
}
|
||||
if (sha1($this->certificateStoreLocal->read($localPath)) != $this->fileHashes[$localPath]) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function writeCertificatesToS3(): void
|
||||
{
|
||||
$this->logger->info(sprintf('%s Uploading Certificates to S3', Emoji::CHARACTER_UP_ARROW));
|
||||
foreach ($this->certificateStoreLocal->listContents('/archive', true) as $file) {
|
||||
/** @var FileAttributes $file */
|
||||
if ($file->isFile()) {
|
||||
$remotePath = str_replace('archive/', '', $file->path());
|
||||
if (!$this->certificateStoreRemote->fileExists($remotePath) || $this->fileChanged($file->path())) {
|
||||
#$this->logger->debug(sprintf(" > Uploading {$file->path()} "));
|
||||
$this->certificateStoreRemote->writeStream($remotePath, $this->certificateStoreLocal->readStream($file->path()));
|
||||
} else {
|
||||
#$this->logger->debug(sprintf(" > Skipping uploading {$file->path()}, file not changed."));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private function generateNginxConfig(BouncerTarget $target): self
|
||||
{
|
||||
$this->configFilesystem->write(
|
||||
$target->getName(),
|
||||
$this->twig->render('NginxTemplate.twig', $target->__toArray())
|
||||
);
|
||||
$this->logger->info(sprintf('%s Created config for %s', Emoji::CHARACTER_PENCIL, $target->getName()));
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BouncerTarget[] $targets
|
||||
*
|
||||
* @return $this
|
||||
*/
|
||||
private function generateLetsEncryptCerts(array $targets): self
|
||||
{
|
||||
foreach ($targets as $target) {
|
||||
if (!$target->isLetsEncrypt()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$testAgeFile = "/archive/{$target->getName()}/fullchain1.pem";
|
||||
if ($this->certificateStoreLocal->fileExists($testAgeFile)) {
|
||||
$ssl = openssl_x509_parse($this->certificateStoreLocal->read($testAgeFile));
|
||||
$timeRemainingSeconds = $ssl['validTo_time_t'] - time();
|
||||
if ($timeRemainingSeconds > 2592000) {
|
||||
$this->logger->info(sprintf(
|
||||
'%s Skipping %s, certificate is still good for %d days',
|
||||
Emoji::CHARACTER_PARTYING_FACE,
|
||||
$target->getName(),
|
||||
round($timeRemainingSeconds / 86400)
|
||||
));
|
||||
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
$shell = new Exec();
|
||||
$command = new CommandBuilder('/usr/bin/certbot');
|
||||
$command->addSubCommand('certonly');
|
||||
$command->addArgument('nginx');
|
||||
if ($this->environment['BOUNCER_LETSENCRYPT_MODE'] != 'production') {
|
||||
$command->addArgument('test-cert');
|
||||
}
|
||||
$command->addFlag('d', implode(',', $target->getDomains()));
|
||||
$command->addFlag('n');
|
||||
$command->addFlag('m', $this->environment['BOUNCER_LETSENCRYPT_EMAIL']);
|
||||
$command->addArgument('agree-tos');
|
||||
$this->logger->info(sprintf('%s Generating letsencrypt for %s - %s', Emoji::CHARACTER_PENCIL, $target->getName(), $command->__toString()));
|
||||
$shell->run($command);
|
||||
|
||||
if ($shell->getReturnValue() == 0) {
|
||||
$this->logger->info(sprintf('%s Generating successful', Emoji::CHARACTER_PARTY_POPPER));
|
||||
} else {
|
||||
$this->logger->critical(sprintf('%s Generating failed!', Emoji::CHARACTER_WARNING));
|
||||
}
|
||||
|
||||
$target->setUseTemporaryCert(false);
|
||||
$this->generateNginxConfig($target);
|
||||
}
|
||||
|
||||
$this->restartNginx();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
private function restartNginx(): void
|
||||
{
|
||||
$shell = new Exec();
|
||||
$command = new CommandBuilder('/usr/sbin/nginx');
|
||||
$command->addFlag('s', 'reload');
|
||||
$this->logger->info(sprintf('%s Restarting nginx', Emoji::CHARACTER_TIMER_CLOCK));
|
||||
$shell->run($command);
|
||||
}
|
||||
}
|
||||
|
||||
(new Bouncer())->run();
|
3
bouncer/bouncer.runit
Executable file
3
bouncer/bouncer.runit
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
echo "Starting Bouncer"
|
||||
/app/bouncer
|
32
bouncer/composer.json
Normal file
32
bouncer/composer.json
Normal file
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"name": "benzine/bouncer",
|
||||
"description": "Nginx Configuration Management",
|
||||
"type": "project",
|
||||
"config": {
|
||||
"sort-packages": true
|
||||
},
|
||||
"license": "GPL-3.0-or-later",
|
||||
"require": {
|
||||
"php": "^8.0",
|
||||
"ext-json": "*",
|
||||
"ext-curl": "*",
|
||||
"kint-php/kint": "^3.3",
|
||||
"guzzlehttp/guzzle": "^7.3",
|
||||
"twig/twig": "^3.0",
|
||||
"league/flysystem": "^2.1",
|
||||
"monolog/monolog": "^2.2",
|
||||
"bramus/monolog-colored-line-formatter": "~3.0",
|
||||
"adambrett/shell-wrapper": "dev-master",
|
||||
"league/flysystem-aws-s3-v3": "^2.1",
|
||||
"spatie/emoji": "^2.3"
|
||||
},
|
||||
"authors": [
|
||||
{
|
||||
"name": "Matthew Baggett",
|
||||
"email": "matthew@baggett.me"
|
||||
}
|
||||
],
|
||||
"require-dev": {
|
||||
"friendsofphp/php-cs-fixer": "^3.0"
|
||||
}
|
||||
}
|
3209
bouncer/composer.lock
generated
Normal file
3209
bouncer/composer.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
37
bouncer/docker-compose.yml
Normal file
37
bouncer/docker-compose.yml
Normal file
|
@ -0,0 +1,37 @@
|
|||
version: "3.4"
|
||||
|
||||
services:
|
||||
bouncer:
|
||||
image: benzine/bouncer
|
||||
build: .
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
- ./:/app
|
||||
environment:
|
||||
- BOUNCER_LETSENCRYPT_MODE=staging
|
||||
- BOUNCER_LETSENCRYPT_EMAIL=matthew@baggett.me
|
||||
- BOUNCER_S3_ENDPOINT=http://grey.ooo:9000
|
||||
- BOUNCER_S3_KEY_ID=geusebio
|
||||
- BOUNCER_S3_KEY_SECRET=changeme
|
||||
- BOUNCER_S3_BUCKET=bouncer-certificates
|
||||
- BOUNCER_S3_USE_PATH_STYLE_ENDPOINT="yes"
|
||||
ports:
|
||||
- 127.0.99.100:80:80
|
||||
- 127.0.99.100:443:443
|
||||
|
||||
web-a:
|
||||
image: benzine/php:nginx
|
||||
volumes:
|
||||
- ./test/public-web-a:/app/public
|
||||
environment:
|
||||
- BOUNCER_DOMAIN=a.web.grey.ooo
|
||||
- BOUNCER_LETSENCRYPT=true
|
||||
|
||||
# web-b:
|
||||
# image: benzine/php:nginx
|
||||
# volumes:
|
||||
# - ./test/public-web-b:/app/public
|
||||
# environment:
|
||||
# - BOUNCER_DOMAIN=b.web.grey.ooo
|
||||
#
|
||||
#
|
43
bouncer/grey-ooo-test.yml
Normal file
43
bouncer/grey-ooo-test.yml
Normal file
|
@ -0,0 +1,43 @@
|
|||
version: "3.4"
|
||||
|
||||
services:
|
||||
bouncer:
|
||||
image: benzine/bouncer
|
||||
volumes:
|
||||
- /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
- BOUNCER_LETSENCRYPT_MODE=production
|
||||
- BOUNCER_LETSENCRYPT_EMAIL=matthew@baggett.me
|
||||
- BOUNCER_S3_ENDPOINT=http://grey.ooo:9000
|
||||
- BOUNCER_S3_KEY_ID=geusebio
|
||||
- BOUNCER_S3_KEY_SECRET=teblE0neTf2NQcVFaZIRkSF44RscyQ3G
|
||||
- BOUNCER_S3_BUCKET=bouncer-certificates
|
||||
- BOUNCER_S3_USE_PATH_STYLE_ENDPOINT="yes"
|
||||
ports:
|
||||
- 80:80
|
||||
- 443:443
|
||||
|
||||
web-a:
|
||||
image: benzine/php:nginx
|
||||
volumes:
|
||||
- ./test/public-web-a:/app/public
|
||||
environment:
|
||||
- BOUNCER_DOMAIN=a.web.grey.ooo
|
||||
- BOUNCER_LETSENCRYPT=true
|
||||
|
||||
web-b:
|
||||
image: benzine/php:nginx
|
||||
volumes:
|
||||
- ./test/public-web-b:/app/public
|
||||
environment:
|
||||
- BOUNCER_DOMAIN=b.web.grey.ooo
|
||||
- BOUNCER_LETSENCRYPT=true
|
||||
|
||||
web-c:
|
||||
image: benzine/php:nginx
|
||||
volumes:
|
||||
- ./test/public-web-c:/app/public
|
||||
environment:
|
||||
- BOUNCER_DOMAIN=c.web.grey.ooo
|
||||
- BOUNCER_LETSENCRYPT=true
|
||||
|
2
bouncer/logs-nginx-access.runit
Executable file
2
bouncer/logs-nginx-access.runit
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
tail -f /var/log/nginx/access.log | sed --unbuffered 's|.*\[.*\] |[NGINX] |g' | grep -v /v1/ping
|
2
bouncer/logs-nginx-error.runit
Executable file
2
bouncer/logs-nginx-error.runit
Executable file
|
@ -0,0 +1,2 @@
|
|||
#!/usr/bin/env bash
|
||||
tail -f /var/log/nginx/error.log
|
6
bouncer/logs.runit
Normal file
6
bouncer/logs.runit
Normal file
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
if [[ -f /var/log/bouncer/*.log ]]; then
|
||||
tail -f /var/log/bouncer/*.log
|
||||
else
|
||||
sleep 1
|
||||
fi
|
3
bouncer/nginx.runit
Executable file
3
bouncer/nginx.runit
Executable file
|
@ -0,0 +1,3 @@
|
|||
#!/usr/bin/env bash
|
||||
echo "Starting Nginx"
|
||||
/usr/sbin/nginx
|
1
bouncer/public/index.html
Normal file
1
bouncer/public/index.html
Normal file
|
@ -0,0 +1 @@
|
|||
Nothing here.
|
22
bouncer/self-signed-certificates/example.crt
Normal file
22
bouncer/self-signed-certificates/example.crt
Normal file
|
@ -0,0 +1,22 @@
|
|||
-----BEGIN CERTIFICATE-----
|
||||
MIIDnTCCAoWgAwIBAgIUJYUUmBQ2/ERZ7xinAJzVhiFWViYwDQYJKoZIhvcNAQEL
|
||||
BQAwXTELMAkGA1UEBhMCVVMxEDAOBgNVBAgMB0Zsb3JpZGExDjAMBgNVBAcMBU1p
|
||||
YW1pMRYwFAYDVQQKDA1FeGFtcGxlIEdyb3VwMRQwEgYDVQQDDAtleGFtcGxlLm9y
|
||||
ZzAgFw0yMTA1MzAxNzU4MzlaGA8yMTIxMDUwNjE3NTgzOVowXTELMAkGA1UEBhMC
|
||||
VVMxEDAOBgNVBAgMB0Zsb3JpZGExDjAMBgNVBAcMBU1pYW1pMRYwFAYDVQQKDA1F
|
||||
eGFtcGxlIEdyb3VwMRQwEgYDVQQDDAtleGFtcGxlLm9yZzCCASIwDQYJKoZIhvcN
|
||||
AQEBBQADggEPADCCAQoCggEBANJa9OcoCW+mej8qDMCCTGnqMAuUqBIj1wZLgOdT
|
||||
4DHriq1vKi1JLsDZkYekrCq/sfWo97kDXsdK6YN4+mua5EN4cTG3mSpal+RgLTc2
|
||||
HMKHFfgzPzIN/n5AEqzdVZb5j0P3LoUNH687AlplW0BB+K64Gw//2KPx0Q8Fkhq2
|
||||
I97V8SRpqds78PJHzhfuZNs/AUFpFXnYHJyO2Q63Btq2aoTMQyoLDRBBxin70II2
|
||||
6Cjh3k6EhMY+HuYS1AjfI8cDQw289asJBLa6zPoD0VGaGNfCSrOzxrUqfhIoOkuY
|
||||
W7rOIsK6rSSu1neSKQIiOLVjQxifxrQIIKTQhRiSplgD9LUCAwEAAaNTMFEwHQYD
|
||||
VR0OBBYEFADK74w4AGeETK72k/htsnol9ye0MB8GA1UdIwQYMBaAFADK74w4AGeE
|
||||
TK72k/htsnol9ye0MA8GA1UdEwEB/wQFMAMBAf8wDQYJKoZIhvcNAQELBQADggEB
|
||||
AKElv0xx95lD2leXEOfD6DKakrzuE8lONmcrkfjehTOd7jbqblnj8u1DCWytwB8P
|
||||
gEr5FXve0iy7avGoNkU33MufbbQokAMoTs/IA+rwMfv0unupT1aYN8TTEXJJ100j
|
||||
MXBsq/PvNkBNwkBcXjYHHsVjdM3bptbaw9A4V9opfMjQXAY5wuk3rBBm8On2rJKy
|
||||
Qksh/uLoe8wbZ5dvLv9oc9sRpIilaSy8TcbrHkDIaWA5WCdVFfcayDGYdjhCYLGW
|
||||
tj/48g0THvJv6JvVYwFJqTM690YUSlxaOHQE2ZneLytocVyAdEL2MMldRezvtI1z
|
||||
1OXOia2G7koNYtS7cD8G1IM=
|
||||
-----END CERTIFICATE-----
|
28
bouncer/self-signed-certificates/example.key
Normal file
28
bouncer/self-signed-certificates/example.key
Normal file
|
@ -0,0 +1,28 @@
|
|||
-----BEGIN PRIVATE KEY-----
|
||||
MIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYwggSiAgEAAoIBAQDSWvTnKAlvpno/
|
||||
KgzAgkxp6jALlKgSI9cGS4DnU+Ax64qtbyotSS7A2ZGHpKwqv7H1qPe5A17HSumD
|
||||
ePprmuRDeHExt5kqWpfkYC03NhzChxX4Mz8yDf5+QBKs3VWW+Y9D9y6FDR+vOwJa
|
||||
ZVtAQfiuuBsP/9ij8dEPBZIatiPe1fEkaanbO/DyR84X7mTbPwFBaRV52BycjtkO
|
||||
twbatmqEzEMqCw0QQcYp+9CCNugo4d5OhITGPh7mEtQI3yPHA0MNvPWrCQS2usz6
|
||||
A9FRmhjXwkqzs8a1Kn4SKDpLmFu6ziLCuq0krtZ3kikCIji1Y0MYn8a0CCCk0IUY
|
||||
kqZYA/S1AgMBAAECggEARqfQjPgwuzTi6OZ55AugGQ9VVf53uagaKH4h7RGKQ5pH
|
||||
OVwWgaGMN7CcpkAUqEM9RjOcCaPtKOmrp8Jx8sTTGSqScs2lf8lwLYB0j4/4dwqi
|
||||
wXyNJIX4znU9EJ1Di3OFwKF9Gam/077xWmWjEeFW43DpfiVEokSuIOqRGbHGOKlt
|
||||
2ygHJu+rmPapEPyYqSWQnAkYX0DW/KCAGiyIAqph/SgrCDTdsxbNOa2OwDygPC54
|
||||
7xW0yCduvgFLh9bxedF8iifzRkPw710cxyqVsYwHiwugDgxL4NiK1DlWbpBimab5
|
||||
ocye9+ElymMZ8DTjpA85cXny/TtoqJfqTs1YGYgrvQKBgQDwHnAcY0BjQ4o+ZneG
|
||||
oqBJeQ8KCMRU4pEIa5QOOeUr46gtiPIfcFh/BJUHQ61qk7gcJj5BV2GXNS7+m+sU
|
||||
RC3Usblm9twwxZn7mfoOk4z9NEfBI2MXmbB8ARjAQBCost+3KQAoSIL1AyDKiAlY
|
||||
2JfMt+73+kwUsg7b9g0pYIfn/wKBgQDgRJPlSIxJs2mbjzUwVBAeslct2W0dehrh
|
||||
V0sXPxEhJHWX6P343vLqRHRsKgqhbU/vy+3JrIS9ftwGKcmb+Y9EJgYrR+D3ZYzs
|
||||
idSOsunSspJgbCG5mHE1VQhr8IpHeCkuSt22aFErLfsjzXWZIewK2tqZN1QUjdc5
|
||||
EJHOD4UDSwKBgFYRYvgZ72NlOzFAw0kkE7YiSWy8Vbtjdr8A6JHs2KNRt9+Sfc8d
|
||||
Eut8dfqjnI5eIpkccCY1rwpnCtBCjRG3moHprl4k0Co/OgGAYKxG4TuFOM8W4xb7
|
||||
hNH+BqQqko4Vh7D8Zk0KKL6v/1n5RvhssoSzzVlfg1PLux3G5VLWggB7AoGAAP/N
|
||||
OORN27Y07kCBGCoHuFtLECU72znEDOT6rKvXQ7KJ45diKk2z/182tZSqX3XBOWxL
|
||||
Lu7Z2I5MJKri/xLplIAm3uJ/GhsVuagTjl81s36gMFXLAKyxNG+gjfqQYykh5dbn
|
||||
jfyBABRAXjR4JaqFBrda6fvZIA5RuytbuvNOwGkCgYAUs82tDGLiqyMPd2jgYS3k
|
||||
aL62f0TLKHjmTCmRca7IqXbqcMbAj+LgAHI2HfCfjc4KWd68ZGRLcpDlehMcis1f
|
||||
PQi3HW+2b9dAZX6+HAIGiVem//ckYXgUza4MMosh0hXquGs1yJ/VNWC+HPIHrj6X
|
||||
9tvvvHnGKav329q/Z/8K/A==
|
||||
-----END PRIVATE KEY-----
|
1
bouncer/test/public-web-a/index.html
Normal file
1
bouncer/test/public-web-a/index.html
Normal file
|
@ -0,0 +1 @@
|
|||
<h1>Website A</h1>
|
1
bouncer/test/public-web-b/index.html
Normal file
1
bouncer/test/public-web-b/index.html
Normal file
|
@ -0,0 +1 @@
|
|||
<h1>Website B</h1>
|
1
bouncer/test/public-web-c/index.html
Normal file
1
bouncer/test/public-web-c/index.html
Normal file
|
@ -0,0 +1 @@
|
|||
<h1>Website C</h1>
|
|
@ -11,7 +11,6 @@ ENV DEBIAN_FRONTEND="teletype" \
|
|||
COLOUR_NONE='\e[39m' \
|
||||
DEFAULT_TZ='Europe/London'
|
||||
|
||||
CMD ["runsvdir", "-P", "/etc/service"]
|
||||
|
||||
WORKDIR /app
|
||||
ENV PATH="/app:/app/bin:/app/vendor/bin:${PATH}"
|
||||
|
@ -20,5 +19,8 @@ COPY installers /installers
|
|||
COPY etc /etc
|
||||
COPY usr /usr
|
||||
|
||||
CMD ["/usr/bin/marshall"]
|
||||
|
||||
RUN /installers/install && \
|
||||
rm -rf /marshall /installers
|
||||
rm -rf /marshall /installers && \
|
||||
chmod +x /usr/bin/marshall
|
||||
|
|
7
marshall/usr/bin/marshall
Executable file
7
marshall/usr/bin/marshall
Executable file
|
@ -0,0 +1,7 @@
|
|||
#!/usr/bin/env bash
|
||||
|
||||
# Fix for windows hosts manging run files
|
||||
dos2unix -q /etc/service/*/run
|
||||
|
||||
# Start Runit.
|
||||
runsvdir -P /etc/service
|
Loading…
Reference in a new issue