Docker-Swarm-Loadbalancer/src/Bouncer.php

939 lines
42 KiB
PHP

<?php
declare(strict_types=1);
namespace Bouncer;
use AdamBrett\ShellWrapper\Command\Builder as CommandBuilder;
use AdamBrett\ShellWrapper\Runners\Exec;
use Aws\S3\S3Client;
use Bouncer\Logger\AbstractLogger;
use GuzzleHttp\Client as Guzzle;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\FileAttributes;
use League\Flysystem\Filesystem;
use League\Flysystem\FilesystemException;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Bouncer\Logger\Logger;
use Bouncer\Logger\Formatter;
use Spatie\Emoji\Emoji;
use Symfony\Component\Yaml\Yaml;
use Twig\Environment as Twig;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
use Twig\Loader\FilesystemLoader as TwigLoader;
use GuzzleHttp\Exception\GuzzleException;
use Monolog\Processor;
use Bouncer\Settings\Settings;
class Bouncer
{
private array $environment;
private Guzzle $docker;
private TwigLoader $loader;
private Twig $twig;
private Filesystem $configFilesystem;
private Filesystem $certificateStoreLocal;
private ?Filesystem $certificateStoreRemote = null;
private Filesystem $providedCertificateStore;
private AbstractLogger $logger;
private array $previousContainerState = [];
private array $previousSwarmState = [];
private array $fileHashes;
private bool $swarmMode = false;
private bool $useGlobalCert = false;
private int $forcedUpdateIntervalSeconds = 0;
private ?int $lastUpdateEpoch = null;
private int $maximumNginxConfigCreationNotices = 15;
private Settings $settings;
private const DEFAULT_DOCKER_SOCKET = '/var/run/docker.sock';
private const FILESYSTEM_CONFIG_DIR = '/etc/nginx/sites-enabled';
private const FILESYSTEM_CERTS_DIR = '/etc/nginx/certs';
private const FILESYSTEM_CERTS_PROVIDED_DIR = '/certs';
public function __construct()
{
$this->environment = array_merge($_ENV, $_SERVER);
ksort($this->environment);
$this->settings = new Settings();
$this->logger = new Logger(
settings: $this->settings,
processIdProcessor: new Processor\ProcessIdProcessor(),
memoryPeakUsageProcessor: new Processor\MemoryPeakUsageProcessor(),
psrLogMessageProcessor: new Processor\PsrLogMessageProcessor(),
coloredLineFormatter: new Formatter\ColourLine($this->settings),
lineFormatter: new Formatter\Line($this->settings),
);
if (isset($this->environment['DOCKER_HOST'])) {
$this->logger->info('Connecting to {docker_host}', ['emoji' => Emoji::electricPlug(), 'docker_host' => $this->environment['DOCKER_HOST']]);
$this->docker = new Guzzle(['base_uri' => $this->environment['DOCKER_HOST']]);
} else {
$this->logger->info('Connecting to {docker_host}', ['emoji' => Emoji::electricPlug(), 'docker_host' => Bouncer::DEFAULT_DOCKER_SOCKET]);
$this->docker = new Guzzle(['base_uri' => 'http://localhost', 'curl' => [CURLOPT_UNIX_SOCKET_PATH => Bouncer::DEFAULT_DOCKER_SOCKET]]);
}
$this->loader = new TwigLoader([__DIR__ . '/../templates']);
$this->twig = new Twig($this->loader);
// Set up Filesystem for sites-enabled path
$this->configFilesystem = new Filesystem(new LocalFilesystemAdapter(Bouncer::FILESYSTEM_CONFIG_DIR));
// Set up Local certificate store
$this->certificateStoreLocal = new Filesystem(new LocalFilesystemAdapter(Bouncer::FILESYSTEM_CERTS_DIR));
// Set up Local certificate store for certificates provided to us
$this->providedCertificateStore = new Filesystem(new LocalFilesystemAdapter(Bouncer::FILESYSTEM_CERTS_PROVIDED_DIR));
// Set up Remote certificate store, if configured
if (isset($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'] ?? ''
)
);
}
}
public function getMaximumNginxConfigCreationNotices(): int
{
return $this->maximumNginxConfigCreationNotices;
}
public function setMaximumNginxConfigCreationNotices(int $maximumNginxConfigCreationNotices): Bouncer
{
$this->maximumNginxConfigCreationNotices = $maximumNginxConfigCreationNotices;
return $this;
}
public function isSwarmMode(): bool
{
return $this->swarmMode;
}
public function setSwarmMode(bool $swarmMode): Bouncer
{
$this->swarmMode = $swarmMode;
return $this;
}
public function isUseGlobalCert(): bool
{
return $this->useGlobalCert;
}
public function setUseGlobalCert(bool $useGlobalCert): Bouncer
{
$this->useGlobalCert = $useGlobalCert;
return $this;
}
public function getForcedUpdateIntervalSeconds(): int
{
return $this->forcedUpdateIntervalSeconds;
}
public function setForcedUpdateIntervalSeconds(int $forcedUpdateIntervalSeconds): Bouncer
{
$this->forcedUpdateIntervalSeconds = $forcedUpdateIntervalSeconds;
return $this;
}
/**
* @return Target[]
*
* @throws GuzzleException
*/
public function findContainersContainerMode(): array
{
$bouncerTargets = [];
$containers = json_decode($this->docker->request('GET', 'containers/json')->getBody()->getContents(), true);
foreach ($containers as $container) {
$envs = [];
$container = json_decode($this->docker->request('GET', "containers/{$container['Id']}/json")->getBody()->getContents(), true);
if (
!isset($container['Config']['Env'])
) {
continue;
}
// Parse all the environment variables and store them in an array.
foreach ($container['Config']['Env'] as $env) {
[$envKey, $envVal] = explode('=', $env, 2);
if (str_starts_with($envKey, 'BOUNCER_')) {
$envs[$envKey] = $envVal;
}
}
ksort($envs);
// If there are no BOUNCER_* environment variables, skip this service.
if (count($envs) == 0) {
continue;
}
// If BOUNCER_IGNORE is set, skip this service.
if (isset($envs['BOUNCER_IGNORE'])) {
continue;
}
if (isset($envs['BOUNCER_DOMAIN'])) {
$bouncerTarget = (new Target(
logger: $this->logger,
settings: $this->settings,
))
->setId($container['Id'])
;
$bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget);
if (!empty($container['NetworkSettings']['IPAddress'])) {
// As per docker service
$bouncerTarget->setEndpointHostnameOrIp($container['NetworkSettings']['IPAddress']);
} else {
// As per docker compose
$networks = array_values($container['NetworkSettings']['Networks']);
$bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']);
}
$bouncerTarget->setTargetPath(sprintf('http://%s:%d', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort() >= 0 ? $bouncerTarget->getPort() : 80));
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
$valid = $bouncerTarget->isEndpointValid();
// $this->logger->debug(sprintf(
// '%s Decided that %s has the endpoint %s and it %s.',
// Emoji::magnifyingGlassTiltedLeft(),
// $bouncerTarget->getName(),
// $bouncerTarget->getEndpointHostnameOrIp(),
// $valid ? 'is valid' : 'is not valid'
// ));
if ($valid) {
$bouncerTargets[] = $bouncerTarget;
}
}
}
return $bouncerTargets;
}
public function findContainersSwarmMode(): array
{
$bouncerTargets = [];
$services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true);
if (isset($services['message'])) {
$this->logger->debug('Something happened while interrogating services.. This node is not a swarm node, cannot have services: {message}', ['emoji' => Emoji::warning() . ' Bouncer.php', 'message' => $services['message']]);
} else {
foreach ($services as $service) {
$envs = [];
if (
!isset($service['Spec'])
|| !isset($service['Spec']['TaskTemplate'])
|| !isset($service['Spec']['TaskTemplate']['ContainerSpec'])
|| !isset($service['Spec']['TaskTemplate']['ContainerSpec']['Env'])
) {
continue;
}
// Parse all the environment variables and store them in an array.
foreach ($service['Spec']['TaskTemplate']['ContainerSpec']['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;
}
$bouncerTarget = (new Target(
logger: $this->logger,
settings: $this->settings,
));
if (isset($envs['BOUNCER_LABEL'])) {
$bouncerTarget->setLabel($envs['BOUNCER_LABEL']);
}
if (isset($envs['BOUNCER_DOMAIN'])) {
$bouncerTarget->setId($service['ID']);
$bouncerTarget->setLabel($service['Spec']['Name']);
$bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget);
if ($bouncerTarget->hasCustomNginxConfig()) {
$this->logger->info('Custom nginx config for {label} is provided.', ['emoji' => Emoji::artistPalette(), 'label' => $bouncerTarget->getLabel()]);
$bouncerTargets[] = $bouncerTarget;
continue;
}
if ($bouncerTarget->isPortSet()) {
$bouncerTarget->setEndpointHostnameOrIp($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()]);
} elseif (isset($service['Endpoint']['Ports'])) {
$bouncerTarget->setEndpointHostnameOrIp('172.17.0.1');
$bouncerTarget->setPort(intval($service['Endpoint']['Ports'][0]['PublishedPort']));
} 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()]);
\Kint::dump(
$bouncerTarget->getId(),
$bouncerTarget->getLabel(),
$envs
);
continue;
}
$bouncerTarget->setTargetPath(sprintf('http://%s:%d', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort()));
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
// @phpstan-ignore-next-line MB: I'm not sure you're right about ->hasCustomNginxConfig only returning false, Stan..
if ($bouncerTarget->isEndpointValid() || $bouncerTarget->hasCustomNginxConfig()) {
$bouncerTargets[] = $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->getEndpointHostnameOrIp(),
]
);
}
}
}
}
return $bouncerTargets;
}
public function run(): void
{
$this->logger->info('Starting Bouncer. Built {build_id} on {build_date}, {build_ago}', ['emoji' => Emoji::redHeart() . ' Bouncer.php', 'build_id' => $this->settings->get('build/id'), 'build_date' => $this->settings->get('build/date')->toDateTimeString(), 'build_ago' => $this->settings->get('build/date')->ago()]);
$this->logger->info('Build #{git_sha}: "{build_message}"', ['emoji' => Emoji::memo(), 'git_sha' => $this->settings->get('build/sha_short'), 'build_message' => $this->settings->get('build/message')]);
$this->logger->debug(' > HTTPS Listener is on {https_port}', ['emoji' => Emoji::ship(), 'https_port' => $this->settings->get('bouncer/https_port')]);
$this->logger->debug(' > HTTP Listener is on {http_port}', ['emoji' => Emoji::ship(), 'http_port' => $this->settings->get('bouncer/http_port')]);
// Allow defined global cert if set
if ($this->settings->has('ssl/global_cert') && $this->settings->has('ssl/global_cert_key')) {
$this->setUseGlobalCert(true);
$this->providedCertificateStore->write('global.crt', str_replace('\\n', "\n", trim($this->settings->get('ssl/global_cert'), '"')));
$this->providedCertificateStore->write('global.key', str_replace('\\n', "\n", trim($this->settings->get('ssl/global_cert_key'), '"')));
}
$this->logger->debug(' > Global Cert is {enabled}', ['emoji' => Emoji::globeShowingEuropeAfrica(), 'enabled' => $this->isUseGlobalCert() ? 'enabled' : 'disabled']);
// Determine forced update interval.
if ($this->settings->has('bouncer/forced_update_interval_seconds')) {
$this->setForcedUpdateIntervalSeconds($this->settings->get('bouncer/forced_update_interval_seconds'));
}
$this->logger->debug(' > Forced Update Interval is {state}', ['emoji' => Emoji::watch(), 'state' => $this->getForcedUpdateIntervalSeconds() > 0 ? $this->getForcedUpdateIntervalSeconds() : 'disabled']);
// Determine maximum notices for nginx config creation.
if ($this->settings->has('bouncer/max_nginx_config_creation_notices')) {
$maxConfigCreationNotices = $this->settings->get('bouncer/max_nginx_config_creation_notices');
$originalMaximumNginxConfigCreationNotices = $this->getMaximumNginxConfigCreationNotices();
$this->setMaximumNginxConfigCreationNotices($maxConfigCreationNotices);
$this->logger->debug(' > Maximum Nginx config creation notices has been over-ridden: {original} => {new}', ['emoji' => Emoji::hikingBoot(), 'original' => $originalMaximumNginxConfigCreationNotices, 'new' => $this->getMaximumNginxConfigCreationNotices()]);
}
// State if non-SSL is allowed. This is processed in the Target class.
$this->logger->debug(' > Allow non-SSL is {enabled}', ['emoji' => Emoji::ship(), 'enabled' => $this->settings->get('ssl/allow_non_ssl') ? 'enabled' : 'disabled']);
try {
$this->stateHasChanged();
} catch (ConnectException $connectException) {
$this->logger->critical('Could not connect to docker socket! Did you forget to map it?', ['emoji' => Emoji::cryingCat()]);
exit(1);
}
// @phpstan-ignore-next-line Yes, I know this is a loop, that is desired.
while (true) {
$this->runLoop();
}
}
public function parseContainerEnvironmentVariables(array $envs, Target $bouncerTarget): Target
{
// Process label and name specifically before all else.
foreach (array_filter($envs) as $envKey => $envVal) {
switch ($envKey) {
case 'BOUNCER_LABEL':
$bouncerTarget->setLabel($envVal);
break;
case 'BOUNCER_DOMAIN':
$domains = explode(',', $envVal);
array_walk($domains, function (&$domain, $key): void {
$domain = trim($domain);
});
$bouncerTarget->setDomains($domains);
break;
}
}
foreach (array_filter($envs) as $envKey => $envVal) {
switch ($envKey) {
case 'BOUNCER_AUTH':
[$username, $password] = explode(':', $envVal);
$bouncerTarget->setAuth($username, $password);
// $this->logger->info('{label}: Basic Auth has been enabled.', ['emoji' => Emoji::key(), 'label' => $bouncerTarget->getLabel(),]);
break;
case 'BOUNCER_HOST_OVERRIDE':
$bouncerTarget->setHostOverride($envVal);
$this->logger->warning('{label}: Host reported to container overridden and set to {host_override}.', ['emoji' => Emoji::hikingBoot(), 'label' => $bouncerTarget->getLabel(), 'host_override' => $bouncerTarget->getHostOverride()]);
break;
case 'BOUNCER_LETSENCRYPT':
$bouncerTarget->setLetsEncrypt(in_array(strtolower($envVal), ['yes', 'true'], true));
break;
case 'BOUNCER_CERT':
$bouncerTarget->setCustomCert($envVal);
$this->logger->info('{label}: Custom cert specified', ['emoji' => Emoji::locked(), 'label' => $bouncerTarget->getLabel()]);
break;
case 'BOUNCER_CERT_KEY':
$bouncerTarget->setCustomCertKey($envVal);
break;
case 'BOUNCER_TARGET_PORT':
$bouncerTarget->setPort(intval($envVal));
// $this->logger->info('{label}: Target port set to {port}.', ['emoji' => Emoji::ship(), 'label' => $bouncerTarget->getLabel(), 'port' => $bouncerTarget->getPort(),]);
break;
case 'BOUNCER_ALLOW_NON_SSL':
$bouncerTarget->setAllowNonSSL(in_array(strtolower($envVal), ['yes', 'true'], true));
break;
case 'BOUNCER_ALLOW_WEBSOCKETS':
$bouncerTarget->setAllowWebsocketSupport(in_array(strtolower($envVal), ['yes', 'true'], true));
break;
case 'BOUNCER_ALLOW_LARGE_PAYLOADS':
$bouncerTarget->setAllowLargePayloads(in_array(strtolower($envVal), ['yes', 'true'], true));
break;
case 'BOUNCER_PROXY_TIMEOUT_SECONDS':
$bouncerTarget->setProxyTimeoutSeconds(is_numeric($envVal) ? intval($envVal) : null);
break;
case 'BOUNCER_CUSTOM_NGINX_CONFIG':
// If envval is base64 encoded, decode it first
if (preg_match('/^[a-zA-Z0-9\/\r\n+]*={0,2}$/', $envVal)) {
$envVal = base64_decode($envVal);
}
$this->logger->info('Custom nginx config for {label} is provided.', ['emoji' => Emoji::artistPalette(), 'label' => $bouncerTarget->getLabel()]);
$bouncerTarget->setCustomNginxConfig($envVal);
break;
}
}
return $bouncerTarget;
}
private function dockerGetContainers(): array
{
return json_decode($this->docker->request('GET', 'containers/json')->getBody()->getContents(), true);
}
private function dockerGetContainer(string $id): array
{
return json_decode($this->docker->request('GET', "containers/{$id}/json")->getBody()->getContents(), true);
}
private function dockerEnvFilter(?array $envs): array
{
if ($envs === null) {
return [];
}
$envs = array_filter(array_map(function ($env) {
if (stripos($env, '=') !== false) {
[$envKey, $envVal] = explode('=', $env, 2);
if (strlen($envVal) > 65) {
return sprintf('%s=CRC32(%s)', $envKey, crc32($envVal));
}
return sprintf('%s=%s', $envKey, $envVal);
}
return $env;
}, $envs));
sort($envs);
return $envs;
}
/**
* Returns true when something has changed.
*
* @throws GuzzleException
*/
private function stateHasChanged(): bool
{
$isTainted = false;
if ($this->lastUpdateEpoch === null) {
$isTainted = true;
} elseif ($this->forcedUpdateIntervalSeconds > 0 && $this->lastUpdateEpoch <= time() - $this->forcedUpdateIntervalSeconds) {
$this->logger->warning('Forced update interval of {interval_seconds} seconds has been reached, forcing update.', ['emoji' => Emoji::watch(), 'interval_seconds' => $this->forcedUpdateIntervalSeconds]);
$isTainted = true;
} elseif ($this->previousContainerState === []) {
$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()]);
$isTainted = true;
}
// Standard Containers
$newContainerState = [];
$containers = $this->dockerGetContainers();
foreach ($containers as $container) {
$inspect = $this->dockerGetContainer($container['Id']);
$name = ltrim($inspect['Name'], '/');
$env = $inspect['Config']['Env'] ?? [];
// if (!$this->dockerEnvHas('BOUNCER_DOMAIN', $env)) {
// continue;
// }
$newContainerState[$name] = [
'name' => $name,
'created' => $inspect['Created'],
'image' => $inspect['Image'],
'status' => $inspect['State']['Status'],
'env' => $this->dockerEnvFilter($env),
];
if (is_array($newContainerState[$name]['env'])) {
sort($newContainerState[$name]['env']);
}
}
ksort($newContainerState);
// Calculate Container State Hash
$containerStateDiff = $this->diff($this->previousContainerState, $newContainerState);
if (!$isTainted && !empty($containerStateDiff)) {
if ($this->settings->if('logger/show_state_deltas')) {
$this->logger->warning('Container state has changed', ['emoji' => Emoji::warning() . ' Bouncer.php']);
echo $containerStateDiff;
}
$isTainted = true;
}
$this->previousContainerState = $newContainerState;
// Swarm Services
$newSwarmState = [];
if ($this->isSwarmMode()) {
$services = json_decode($this->docker->request('GET', 'services')->getBody()->getContents(), true);
if (isset($services['message'])) {
$this->logger->warning('Something happened while interrogating services.. This node is not a swarm node, cannot have services: {message}', ['emoji' => Emoji::warning() . ' Bouncer.php', 'message' => $services['message']]);
} else {
foreach ($services as $service) {
$name = $service['Spec']['Name'];
$env = $service['Spec']['TaskTemplate']['ContainerSpec']['Env'] ?? [];
// if (!$this->dockerEnvHas('BOUNCER_DOMAIN', $env)) {
// continue;
// }
$newSwarmState[$name] = [
'id' => $service['ID'],
'mode' => isset($service['Spec']['Mode']['Replicated']) ?
sprintf('replicated:%d', $service['Spec']['Mode']['Replicated']['Replicas']) :
(isset($service['Spec']['Mode']['Global']) ? 'global' : 'none'),
'created' => $service['CreatedAt'],
'image' => $service['Spec']['TaskTemplate']['ContainerSpec']['Image'],
'versionIndex' => $service['Version']['Index'],
'updateStatus' => $service['UpdateStatus']['State'] ?? 'unknown',
'env' => $this->dockerEnvFilter($env),
];
}
}
}
ksort($newSwarmState);
// Calculate Swarm State Hash, if applicable
$swarmStateDiff = $this->diff($this->previousSwarmState, $newSwarmState);
if ($this->isSwarmMode() && !$isTainted && !empty($swarmStateDiff)) {
if ($this->settings->if('logger/show_state_deltas')) {
$this->logger->warning('Swarm state has changed', ['emoji' => Emoji::warning() . ' Bouncer.php']);
echo $swarmStateDiff;
}
$isTainted = true;
}
$this->previousSwarmState = $newSwarmState;
return $isTainted;
}
private function diff($a, $b)
{
return (new \Diff(
explode(
"\n",
Yaml::dump(input: $a, inline: 5, indent: 2)
),
explode(
"\n",
Yaml::dump(input: $b, inline: 5, indent: 2)
)
))->render(new \Diff_Renderer_Text_Unified());
}
private function runLoop(): void
{
if ($this->s3Enabled()) {
$this->getCertificatesFromS3();
}
try {
$determineSwarmMode = json_decode($this->docker->request('GET', 'swarm')->getBody()->getContents(), true);
$this->setSwarmMode(!isset($determineSwarmMode['message']));
} catch (ServerException $exception) {
$this->setSwarmMode(false);
} catch (ConnectException $exception) {
$this->logger->critical('Unable to connect to docker socket!', ['emoji' => Emoji::warning() . ' Bouncer.php']);
$this->logger->critical($exception->getMessage());
exit(1);
}
$this->logger->debug(' > Swarm mode is {enabled}.', ['emoji' => Emoji::honeybee(), 'enabled' => $this->isSwarmMode() ? 'enabled' : 'disabled']);
$targets = array_values(
array_merge(
$this->findContainersContainerMode(),
$this->isSwarmMode() ? $this->findContainersSwarmMode() : []
)
);
// Use some bs to sort the targets by domain from right to left.
$sortedTargets = [];
foreach ($targets as $target) {
$sortedTargets[strrev($target->getName())] = $target;
}
ksort($sortedTargets);
$targets = array_values($sortedTargets);
// Re-generate nginx configs
$this->logger->info('Found {num_services} services with BOUNCER_DOMAIN set', ['emoji' => Emoji::magnifyingGlassTiltedLeft(), 'num_services' => count($targets)]);
$this->generateNginxConfigs($targets);
$this->generateLetsEncryptCerts($targets);
if ($this->s3Enabled()) {
$this->writeCertificatesToS3();
}
// if any of the targets has requiresForcedScanning set to true, we need to force an update
if (array_reduce($targets, fn ($carry, $target) => $carry || $target->requiresForcedScanning(), false)) {
$this->logger->warning('Forcing an update in 5 seconds because one or more targets require it.', ['emoji' => Emoji::warning()]);
sleep(5);
return;
}
// Wait for next change
$this->waitUntilContainerChange();
}
private function waitUntilContainerChange(): void
{
while ($this->stateHasChanged() === false) {
sleep(5);
}
$this->lastUpdateEpoch = time();
}
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()}";
if ($file->fileSize() == 0) {
$this->logger->warning(sprintf(' > Downloading %s to %s was skipped, because it was empty', $file->path(), $localPath));
continue;
}
$this->logger->debug(sprintf(' > Downloading %s to %s (%d bytes)', $file->path(), $localPath, $file->fileSize()));
$this->certificateStoreLocal->writeStream($localPath, $this->certificateStoreRemote->readStream($file->path()));
if ($this->certificateStoreLocal->fileSize($localPath) == $this->certificateStoreRemote->fileSize($file->path())) {
$this->logger->debug(sprintf(' > Filesize for %s matches %s on remote (%d bytes)', $localPath, $file->path(), $this->certificateStoreLocal->fileSize($localPath)));
} else {
$this->logger->critical(sprintf(' > Filesize for %s DOES NOT MATCH %s on remote (%d != %d bytes)', $localPath, $file->path(), $this->certificateStoreLocal->fileSize($localPath), $this->certificateStoreRemote->fileSize($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());
// Stupid dirty hack bullshit reee
for ($i = 1; $i <= 9; ++$i) {
$livePath = str_replace("{$i}.pem", '.pem', $livePath);
}
$this->logger->debug(sprintf(' > Mirroring %s to %s (%d bytes)', $newLocalCert->path(), $livePath, $newLocalCert->fileSize()));
$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('Uploading Certificates to S3', ['emoji' => 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 ($file->fileSize() == 0) {
$this->logger->warning(' > Skipping uploading {file}, file is garbage (empty).', ['file' => $file->path()]);
} elseif (!$this->certificateStoreRemote->fileExists($remotePath) || $this->fileChanged($file->path())) {
$this->logger->debug(' > Uploading {file} ({bytes} bytes)', ['file' => $file->path(), 'bytes' => $file->fileSize()]);
$this->certificateStoreRemote->write($remotePath, $this->certificateStoreLocal->read($file->path()));
} else {
$this->logger->debug(' > Skipping uploading {file}, file not changed.', ['file' => $file->path()]);
}
}
}
}
/**
* @param $targets Target[]
*/
private function generateNginxConfigs(array $targets): void
{
$changedTargets = [];
foreach ($targets as $target) {
if ($this->generateNginxConfig($target)) {
$changedTargets[strrev($target->getName())] = $target;
}
}
// @var Target[] $changedTargets
ksort($changedTargets);
$changedTargets = array_values($changedTargets);
if (count($changedTargets) <= $this->getMaximumNginxConfigCreationNotices()) {
/** @var Target $target */
foreach ($changedTargets as $target) {
$context = [
'label' => $target->getLabel(),
'domain' => $target->getPresentationdomain(),
'file' => $target->getNginxConfigFileName(),
'config_dir' => Bouncer::FILESYSTEM_CONFIG_DIR,
];
$this->logger->info('Created {label}', $context + ['emoji' => Emoji::pencil() . ' Bouncer.php']);
$this->logger->debug(' -> {config_dir}/{file}', $context + ['emoji' => Emoji::pencil() . ' Bouncer.php']);
$this->logger->debug(' -> {domain}', $context + ['emoji' => Emoji::pencil() . ' Bouncer.php']);
$this->logger->critical('{label} cert type is {cert_type}', $context + ['emoji' => Emoji::catFace(), 'cert_type' => $target->getTypeCertInUse()->name]);
}
} else {
$this->logger->info('More than {num_max} Nginx configs generated.. Too many to show them all!', ['emoji' => Emoji::pencil() . ' Bouncer.php', 'num_max' => $this->getMaximumNginxConfigCreationNotices()]);
}
$this->logger->info('Updated {num_created} Nginx configs, {num_changed} changed..', ['emoji' => Emoji::pencil() . ' Bouncer.php', 'num_created' => count($targets), 'num_changed' => count($changedTargets)]);
$this->pruneNonExistentConfigs($targets);
}
/**
* @param $targets Target[]
*
* @throws FilesystemException
*/
protected function pruneNonExistentConfigs(array $targets): void
{
$expectedFiles = [
'default.conf',
];
foreach ($targets as $target) {
$expectedFiles = array_merge($expectedFiles, $target->getExpectedFiles());
}
foreach ($this->configFilesystem->listContents('/') as $file) {
if (!in_array($file['path'], $expectedFiles)) {
$this->logger->info('Removing {file}', ['emoji' => Emoji::wastebasket(), 'file' => $file['path']]);
$this->configFilesystem->delete($file['path']);
}
}
}
/**
* @throws FilesystemException
* @throws LoaderError
* @throws RuntimeError
* @throws SyntaxError
*/
private function generateNginxConfig(Target $target): bool
{
$configData = $target->hasCustomNginxConfig() ? $target->getCustomNginxConfig() : $this->twig->render('NginxTemplate.twig', $target->__toArray());
$changed = false;
$configFileHash = $this->configFilesystem->fileExists($target->getNginxConfigFileName()) ? sha1($this->configFilesystem->read($target->getNginxConfigFileName())) : null;
if (sha1($configData) != $configFileHash) {
$this->configFilesystem->write($target->getNginxConfigFileName(), $configData);
$changed = true;
}
if ($target->isUseCustomCert()) {
$this->configFilesystem->write($target->getCustomCertPath(), $target->getCustomCert());
$this->configFilesystem->write($target->getCustomCertKeyPath(), $target->getCustomCertKey());
}
if ($target->hasAuth()) {
$authFileHash = $this->configFilesystem->fileExists($target->getBasicAuthFileName()) ? $this->configFilesystem->read($target->getBasicAuthHashFileName()) : null;
if ($target->getAuthHash() != $authFileHash) {
$this->configFilesystem->write($target->getBasicAuthHashFileName(), $target->getAuthHash());
$this->configFilesystem->write($target->getBasicAuthFileName(), $target->getBasicAuthFileData());
$changed = true;
}
}
return $changed;
}
/**
* @param Target[] $targets
*
* @throws FilesystemException
*/
private function generateLetsEncryptCerts(array $targets): void
{
foreach ($targets as $target) {
if (!$target->isLetsEncrypt()) {
continue;
}
$testAgeFile = "/archive/{$target->getName()}/fullchain1.pem";
if ($this->certificateStoreLocal->fileExists($testAgeFile)) {
$dubious = false;
if ($this->certificateStoreLocal->fileSize($testAgeFile) == 0) {
// File is empty, check its age instead.
$timeRemainingSeconds = $this->certificateStoreLocal->lastModified($testAgeFile) - time();
$dubious = true;
} else {
$ssl = openssl_x509_parse($this->certificateStoreLocal->read($testAgeFile));
$timeRemainingSeconds = $ssl['validTo_time_t'] - time();
}
if ($timeRemainingSeconds > 2592000) {
$this->logger->info(
'Skipping {target_name}, certificate is {validity} for {duration_days} days',
[
'emoji' => Emoji::CHARACTER_PARTYING_FACE,
'target_name' => $target->getName(),
'validity' => $dubious ? 'dubiously good' : 'still good',
'duration_days' => round($timeRemainingSeconds / 86400),
]
);
$target->setUseTemporaryCert(false);
$this->generateNginxConfig($target);
continue;
}
}
// Start running shell commands...
$shell = new Exec();
// Disable nginx tweaks
$this->logger->debug('Moving nginx tweak file out of the way..', ['emoji' => Emoji::rightArrow()]);
$disableNginxTweaksCommand = (new CommandBuilder('mv'))
->addSubCommand('/etc/nginx/conf.d/tweak.conf')
->addSubCommand('/etc/nginx/conf.d/tweak.disabled')
;
$shell->run($disableNginxTweaksCommand);
// Generate letsencrypt cert
$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('Generating letsencrypt for {target_name} - {command}', ['emoji' => Emoji::pencil() . ' Bouncer.php', 'target_name' => $target->getName(), 'command' => $command->__toString()]);
$shell->run($command);
if ($shell->getReturnValue() == 0) {
$this->logger->info('Generating successful', ['emoji' => Emoji::partyPopper()]);
} else {
$this->logger->critical('Generating failed!', ['emoji' => Emoji::warning() . ' Bouncer.php']);
}
// Re-enable nginx tweaks
$this->logger->debug('Moving nginx tweak file back in place..', ['emoji' => Emoji::leftArrow()]);
$disableNginxTweaksCommand = (new CommandBuilder('mv'))
->addSubCommand('/etc/nginx/conf.d/tweak.disabled')
->addSubCommand('/etc/nginx/conf.d/tweak.conf')
;
$shell->run($disableNginxTweaksCommand);
$target->setUseTemporaryCert(false);
$this->generateNginxConfig($target);
}
$this->restartNginx();
}
private function restartNginx(): void
{
$shell = new Exec();
$command = new CommandBuilder('/usr/sbin/nginx');
$command->addFlag('s', 'reload');
$this->logger->info('Restarting nginx', ['emoji' => Emoji::timerClock() . ' Bouncer.php']);
$nginxRestartOutput = $shell->run($command);
$this->logger->debug('Nginx restarted {restart_output}', ['restart_output' => $nginxRestartOutput, 'emoji' => Emoji::partyPopper()]);
}
}