2021-06-06 15:38:46 +00:00
#!/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;
2023-01-09 14:57:00 +00:00
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Exception\ServerException;
2021-06-06 15:38:46 +00:00
use League\Flysystem\AwsS3V3\AwsS3V3Adapter;
use League\Flysystem\FileAttributes;
use League\Flysystem\Filesystem;
use League\Flysystem\Local\LocalFilesystemAdapter;
use Monolog\Handler\StreamHandler;
2024-01-05 17:15:51 +00:00
use Monolog\Level;
2021-06-06 15:38:46 +00:00
use Monolog\Logger;
use Spatie\Emoji\Emoji;
2024-01-05 17:15:51 +00:00
use Symfony\Component\Yaml\Yaml;
2021-06-06 15:38:46 +00:00
use Twig\Environment;
use Twig\Loader\FilesystemLoader;
class BouncerTarget
{
private string $id;
private array $domains;
2022-08-09 00:28:22 +00:00
private string $endpointHostnameOrIp;
2024-01-05 17:16:21 +00:00
private ?int $port = null;
2021-06-06 15:38:46 +00:00
private bool $letsEncrypt = false;
private string $targetPath;
2024-01-05 17:16:21 +00:00
private bool $allowNonSSL = true;
private bool $useTemporaryCert = true;
private bool $useGlobalCert = false;
2022-06-15 09:22:24 +00:00
private bool $allowWebsocketSupport = true;
2024-01-05 17:16:21 +00:00
private bool $allowLargePayloads = false;
private ?int $proxyTimeoutSeconds = null;
private ?string $username = null;
private ?string $password = null;
2021-06-06 15:38:46 +00:00
2022-08-09 00:28:22 +00:00
public function __construct(
private Logger $logger
) {
}
2021-06-06 15:38:46 +00:00
public function __toArray()
{
return [
2024-01-05 17:16:21 +00:00
'id' => $this->getId(),
'name' => $this->getName(),
'domains' => $this->getDomains(),
'letsEncrypt' => $this->isLetsEncrypt(),
'targetPath' => $this->getTargetPath(),
'useTemporaryCert' => $this->isUseTemporaryCert(),
'useGlobalCert' => $this->isUseGlobalCert(),
'allowNonSSL' => $this->isAllowNonSSL(),
2022-06-15 09:22:24 +00:00
'allowWebsocketSupport' => $this->isAllowWebsocketSupport(),
2024-01-05 17:16:21 +00:00
'allowLargePayloads' => $this->isAllowLargePayloads(),
'proxyTimeoutSeconds' => $this->getProxyTimeoutSeconds(),
'hasAuth' => $this->hasAuth(),
'authFile' => $this->getAuthFileName(),
2021-06-06 15:38:46 +00:00
];
}
2023-01-09 14:57:00 +00:00
public function getUsername(): ?string
2022-08-09 01:34:22 +00:00
{
2023-01-09 14:57:00 +00:00
return $this->username;
2022-08-09 01:34:22 +00:00
}
2023-01-09 14:57:00 +00:00
/**
* @param string
*/
public function setUsername(string $username): BouncerTarget
2021-06-06 15:38:46 +00:00
{
2023-01-09 14:57:00 +00:00
$this->username = $username;
2024-01-05 14:47:37 +00:00
2023-01-09 14:57:00 +00:00
return $this;
2021-06-06 15:38:46 +00:00
}
2023-01-09 14:57:00 +00:00
public function getPassword(): ?string
2021-06-06 15:38:46 +00:00
{
2023-01-09 14:57:00 +00:00
return $this->password;
}
2021-06-06 15:38:46 +00:00
2023-01-09 14:57:00 +00:00
public function setPassword(string $password): BouncerTarget
{
$this->password = $password;
2024-01-05 14:47:37 +00:00
2021-06-06 15:38:46 +00:00
return $this;
}
2023-01-09 14:57:00 +00:00
public function setAuth(string $username, string $password): BouncerTarget
{
return $this->setUsername($username)->setPassword($password);
}
2024-01-05 14:47:37 +00:00
public function hasAuth(): bool
2022-08-09 01:34:22 +00:00
{
2023-01-09 14:57:00 +00:00
return $this->username != null && $this->password != null;
2022-08-09 01:34:22 +00:00
}
2024-01-05 14:47:37 +00:00
public function getFileName(): string
2022-08-09 01:34:22 +00:00
{
2023-01-09 14:57:00 +00:00
return "{$this->getName()}.conf";
}
2024-01-05 14:47:37 +00:00
public function getAuthFileName(): string
2023-01-09 14:57:00 +00:00
{
return "{$this->getName()}.secret";
}
2024-01-05 14:47:37 +00:00
public function getAuthFileData(): string
2023-01-09 14:57:00 +00:00
{
2024-01-05 14:47:37 +00:00
$output = shell_exec(sprintf('htpasswd -nibB -C10 %s %s', $this->getUsername(), $this->getPassword()));
2024-01-05 17:16:21 +00:00
return trim($output) . "\n";
2023-01-09 14:57:00 +00:00
}
public function getProxyTimeoutSeconds(): ?int
{
return $this->proxyTimeoutSeconds;
}
public function setProxyTimeoutSeconds(?int $proxyTimeoutSeconds): BouncerTarget
{
$this->proxyTimeoutSeconds = $proxyTimeoutSeconds;
return $this;
}
public function isUseTemporaryCert(): bool
{
return $this->useTemporaryCert;
}
public function setUseTemporaryCert(bool $useTemporaryCert): BouncerTarget
{
$this->useTemporaryCert = $useTemporaryCert;
2022-08-09 01:34:22 +00:00
return $this;
}
2022-08-09 00:28:22 +00:00
public function isUseGlobalCert(): bool
{
return $this->useGlobalCert;
}
public function setUseGlobalCert(bool $useGlobalCert): BouncerTarget
{
$this->useGlobalCert = $useGlobalCert;
// Global cert overrides temporary certs.
2024-01-05 14:47:37 +00:00
if ($useGlobalCert) {
2023-01-09 14:57:00 +00:00
$this->setUseTemporaryCert(false);
}
2022-08-09 00:28:22 +00:00
return $this;
}
2022-06-15 09:22:24 +00:00
public function isAllowWebsocketSupport(): bool
{
return $this->allowWebsocketSupport;
}
public function setAllowWebsocketSupport(bool $allowWebsocketSupport): BouncerTarget
{
$this->allowWebsocketSupport = $allowWebsocketSupport;
return $this;
}
2022-06-15 09:34:07 +00:00
public function isAllowLargePayloads(): bool
2022-06-15 09:22:24 +00:00
{
2022-06-15 09:34:07 +00:00
return $this->allowLargePayloads;
2022-06-15 09:22:24 +00:00
}
2022-06-15 09:34:07 +00:00
public function setAllowLargePayloads(bool $allowLargePayloads): BouncerTarget
2022-06-15 09:22:24 +00:00
{
2022-06-15 09:34:07 +00:00
$this->allowLargePayloads = $allowLargePayloads;
2022-06-15 09:22:24 +00:00
return $this;
}
2021-06-06 15:38:46 +00:00
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;
}
2022-08-09 00:28:22 +00:00
public function getEndpointHostnameOrIp(): string
2021-06-06 15:38:46 +00:00
{
2022-08-09 00:28:22 +00:00
return $this->endpointHostnameOrIp;
2021-06-06 15:38:46 +00:00
}
2022-08-09 00:28:22 +00:00
public function setEndpointHostnameOrIp(string $endpointHostnameOrIp): BouncerTarget
2021-06-06 15:38:46 +00:00
{
2022-08-09 00:28:22 +00:00
$this->endpointHostnameOrIp = $endpointHostnameOrIp;
2021-06-06 15:38:46 +00:00
return $this;
}
2022-08-09 00:28:22 +00:00
public function getPort(): ?int
2021-06-06 15:38:46 +00:00
{
return $this->port;
}
2022-08-09 00:28:22 +00:00
public function isPortSet(): bool
{
return $this->port !== null;
}
2021-06-06 15:38:46 +00:00
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;
}
2022-08-09 00:28:22 +00:00
public function isEndpointValid(): bool
{
// Is it just an IP?
if (filter_var($this->getEndpointHostnameOrIp(), FILTER_VALIDATE_IP)) {
// $this->logger->debug(sprintf('%s isEndpointValid: %s is a normal IP', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp()));
return true;
}
// Is it a Hostname that resolves?
$resolved = gethostbyname($this->getEndpointHostnameOrIp());
if (filter_var($resolved, FILTER_VALIDATE_IP)) {
// $this->logger->debug(sprintf('%s isEndpointValid: %s is a hostname that resolves to a normal IP %s', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp(), $resolved));
return true;
}
2023-09-13 12:37:21 +00:00
$this->logger->warning(sprintf('%s isEndpointValid: %s is a hostname that does not resolve', Emoji::magnifyingGlassTiltedRight(), $this->getEndpointHostnameOrIp()));
2022-08-09 00:28:22 +00:00
return false;
}
2024-01-05 18:51:03 +00:00
public function getPresentationDomain(): string
{
return sprintf(
'%s://%s%s',
$this->isAllowNonSSL() ? 'http' : 'https',
$this->getUsername() && $this->getPassword() ?
sprintf('%s:%s@', $this->getUsername(), $this->getPassword()) :
'',
$this->getName()
);
}
2021-06-06 15:38:46 +00:00
}
class Bouncer
{
private array $environment;
private Guzzle $client;
private FilesystemLoader $loader;
private Environment $twig;
private Filesystem $configFilesystem;
private Filesystem $certificateStoreLocal;
2022-05-05 10:34:04 +00:00
private ?Filesystem $certificateStoreRemote = null;
2022-08-09 00:28:22 +00:00
private Filesystem $providedCertificateStore;
2021-06-06 15:38:46 +00:00
private Logger $logger;
2024-01-05 14:47:37 +00:00
private array $previousContainerState = [];
2024-01-05 17:16:21 +00:00
private array $previousSwarmState = [];
2021-06-06 15:38:46 +00:00
private array $fileHashes;
2024-01-05 17:16:21 +00:00
private bool $swarmMode = false;
private bool $useGlobalCert = false;
private int $forcedUpdateIntervalSeconds = 0;
private ?int $lastUpdateEpoch = null;
2023-01-09 14:57:00 +00:00
private int $maximumNginxConfigCreationNotices = 15;
2022-05-05 14:42:53 +00:00
2021-06-06 15:38:46 +00:00
public function __construct()
{
$this->environment = array_merge($_ENV, $_SERVER);
ksort($this->environment);
$this->logger = new Monolog\Logger('bouncer');
2024-01-05 17:15:51 +00:00
$this->logger->pushHandler(new StreamHandler('/var/log/bouncer.log', Level::Debug));
$stdout = new StreamHandler('php://stdout', Level::Debug);
$stdout->setFormatter(new ColoredLineFormatter(
format: "%level_name%: %message% \n",
allowInlineLineBreaks: true,
ignoreEmptyContextAndExtra: true,
));
2021-06-06 15:38:46 +00:00
$this->logger->pushHandler($stdout);
2022-08-09 00:28:22 +00:00
if (isset($this->environment['DOCKER_HOST'])) {
$this->logger->info(sprintf('%s Connecting to %s', Emoji::electricPlug(), $this->environment['DOCKER_HOST']));
$this->client = new Guzzle(['base_uri' => $this->environment['DOCKER_HOST']]);
} else {
$this->logger->info(sprintf('%s Connecting to /var/run/docker.sock', Emoji::electricPlug()));
$this->client = new Guzzle(['base_uri' => 'http://localhost', 'curl' => [CURLOPT_UNIX_SOCKET_PATH => '/var/run/docker.sock']]);
}
2021-06-06 15:38:46 +00:00
$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'));
2022-08-09 00:28:22 +00:00
// Set up Local certificate store for certificates provided to us
$this->providedCertificateStore = new Filesystem(new LocalFilesystemAdapter('/certs'));
2021-06-06 15:38:46 +00:00
// Set up Remote certificate store, if configured
2022-05-05 10:34:04 +00:00
if (isset($this->environment['BOUNCER_S3_BUCKET'])) {
2021-06-06 15:38:46 +00:00
$this->certificateStoreRemote = new Filesystem(
new AwsS3V3Adapter(
new S3Client([
2024-01-05 17:16:21 +00:00
'endpoint' => $this->environment['BOUNCER_S3_ENDPOINT'],
2021-06-06 15:38:46 +00:00
'use_path_style_endpoint' => isset($this->environment['BOUNCER_S3_USE_PATH_STYLE_ENDPOINT']),
2024-01-05 17:16:21 +00:00
'credentials' => [
'key' => $this->environment['BOUNCER_S3_KEY_ID'],
2021-06-06 15:38:46 +00:00
'secret' => $this->environment['BOUNCER_S3_KEY_SECRET'],
],
2024-01-05 17:16:21 +00:00
'region' => $this->environment['BOUNCER_S3_REGION'] ?? 'us-east',
2021-06-06 15:38:46 +00:00
'version' => 'latest',
]),
$this->environment['BOUNCER_S3_BUCKET'],
$this->environment['BOUNCER_S3_PREFIX'] ?? ''
)
);
}
2022-08-09 00:28:22 +00:00
// Allow defined global cert if set
if (isset($this->environment['GLOBAL_CERT'], $this->environment['GLOBAL_CERT_KEY'])) {
$this->setUseGlobalCert(true);
$this->providedCertificateStore->write('global.crt', str_replace('\\n', "\n", trim($this->environment['GLOBAL_CERT'], '"')));
$this->providedCertificateStore->write('global.key', str_replace('\\n', "\n", trim($this->environment['GLOBAL_CERT_KEY'], '"')));
$this->logger->info(sprintf("%s GLOBAL_CERT was set, so we're going to use a defined certificate!", Emoji::globeShowingEuropeAfrica()));
}
// Determine forced update interval.
if (isset($this->environment['BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS']) && is_numeric($this->environment['BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS'])) {
$this->setForcedUpdateIntervalSeconds($this->environment['BOUNCER_FORCED_UPDATE_INTERVAL_SECONDS']);
}
2024-01-05 18:51:03 +00:00
if ($this->getForcedUpdateIntervalSeconds() > 0) {
$this->logger->warning(sprintf('%s Forced update interval is every %d seconds', Emoji::watch(), $this->getForcedUpdateIntervalSeconds()));
} else {
$this->logger->info(sprintf('%s Forced update interval is disabled', Emoji::watch()));
}
2024-01-04 13:06:46 +00:00
// Determine maximum notices for nginx config creation.
if (isset($this->environment['BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES']) && is_numeric($this->environment['BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES'])) {
$originalMaximumNginxConfigCreationNotices = $this->getMaximumNginxConfigCreationNotices();
$this->setMaximumNginxConfigCreationNotices($this->environment['BOUNCER_MAXIMUM_NGINX_CONFIG_CREATION_NOTICES']);
$this->logger->warning(sprintf('%s Maximum Nginx config creation notices has been over-ridden: %d => %d', Emoji::upsideDownFace(), $originalMaximumNginxConfigCreationNotices, $this->getMaximumNginxConfigCreationNotices()));
}
2021-06-06 15:38:46 +00:00
}
2023-01-09 14:57:00 +00:00
public function getMaximumNginxConfigCreationNotices(): int
{
return $this->maximumNginxConfigCreationNotices;
}
public function setMaximumNginxConfigCreationNotices(int $maximumNginxConfigCreationNotices): Bouncer
{
$this->maximumNginxConfigCreationNotices = $maximumNginxConfigCreationNotices;
return $this;
}
2022-06-15 09:22:24 +00:00
public function isSwarmMode(): bool
{
return $this->swarmMode;
}
public function setSwarmMode(bool $swarmMode): Bouncer
{
$this->swarmMode = $swarmMode;
return $this;
}
2022-08-09 00:28:22 +00:00
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;
}
2021-06-06 15:38:46 +00:00
/**
* @return BouncerTarget[]
2024-01-05 14:47:37 +00:00
*
* @throws \GuzzleHttp\Exception\GuzzleException
2021-06-06 15:38:46 +00:00
*/
2022-05-05 14:42:53 +00:00
public function findContainersContainerMode(): array
2021-06-06 15:38:46 +00:00
{
$bouncerTargets = [];
2022-05-05 14:42:53 +00:00
2021-06-06 15:38:46 +00:00
$containers = json_decode($this->client->request('GET', 'containers/json')->getBody()->getContents(), true);
foreach ($containers as $container) {
2024-01-05 17:16:21 +00:00
$envs = [];
2021-06-06 15:38:46 +00:00
$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);
2024-01-05 17:16:21 +00:00
$envs[$envKey] = $envVal;
2021-06-06 15:38:46 +00:00
} else {
2022-08-09 00:28:22 +00:00
$envs[$environmentItem] = true;
2021-06-06 15:38:46 +00:00
}
}
}
if (isset($envs['BOUNCER_DOMAIN'])) {
2022-08-09 00:28:22 +00:00
$bouncerTarget = (new BouncerTarget($this->logger))
2021-06-06 15:38:46 +00:00
->setId($inspect['Id'])
;
2022-05-05 14:42:53 +00:00
$bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget);
2021-06-06 15:38:46 +00:00
if (isset($inspect['NetworkSettings']['IPAddress']) && !empty($inspect['NetworkSettings']['IPAddress'])) {
// As per docker service
2022-08-09 00:28:22 +00:00
$bouncerTarget->setEndpointHostnameOrIp($inspect['NetworkSettings']['IPAddress']);
2021-06-06 15:38:46 +00:00
} else {
// As per docker compose
$networks = array_values($inspect['NetworkSettings']['Networks']);
2022-08-09 00:28:22 +00:00
$bouncerTarget->setEndpointHostnameOrIp($networks[0]['IPAddress']);
2021-06-06 15:38:46 +00:00
}
2022-08-09 00:28:22 +00:00
$bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort() >= 0 ? $bouncerTarget->getPort() : 80));
2021-06-06 15:38:46 +00:00
2022-08-09 00:28:22 +00:00
$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) {
2023-01-09 14:57:00 +00:00
$bouncerTargets[] = $bouncerTarget;
2022-08-09 00:28:22 +00:00
}
2021-06-06 15:38:46 +00:00
}
}
2022-06-15 09:22:24 +00:00
2022-05-05 14:42:53 +00:00
return $bouncerTargets;
}
2022-06-15 09:22:24 +00:00
2022-05-05 14:42:53 +00:00
public function findContainersSwarmMode(): array
{
$bouncerTargets = [];
2024-01-05 17:16:21 +00:00
$services = json_decode($this->client->request('GET', 'services')->getBody()->getContents(), true);
2022-05-05 14:42:53 +00:00
2022-06-15 09:22:24 +00:00
if (isset($services['message'])) {
2022-05-05 14:42:53 +00:00
$this->logger->debug(sprintf('Something happened while interrogating services.. This node is not a swarm node, cannot have services: %s', $services['message']));
2022-06-15 09:22:24 +00:00
} else {
foreach ($services as $service) {
2022-05-05 14:42:53 +00:00
$envs = [];
2022-06-15 09:22:24 +00:00
if (
2022-05-05 15:35:09 +00:00
!isset($service['Spec'])
|| !isset($service['Spec']['TaskTemplate'])
|| !isset($service['Spec']['TaskTemplate']['ContainerSpec'])
|| !isset($service['Spec']['TaskTemplate']['ContainerSpec']['Env'])
2022-06-15 09:22:24 +00:00
) {
2022-05-05 15:35:09 +00:00
continue;
}
2022-06-15 09:22:24 +00:00
foreach ($service['Spec']['TaskTemplate']['ContainerSpec']['Env'] as $env) {
[$eKey, $eVal] = explode('=', $env, 2);
2024-01-05 17:16:21 +00:00
$envs[$eKey] = $eVal;
2022-05-05 14:42:53 +00:00
}
2022-06-15 09:22:24 +00:00
if (isset($envs['BOUNCER_DOMAIN'])) {
2022-08-09 00:28:22 +00:00
$bouncerTarget = (new BouncerTarget($this->logger))
2022-06-15 09:22:24 +00:00
->setId($service['ID'])
;
2022-05-05 14:42:53 +00:00
$bouncerTarget = $this->parseContainerEnvironmentVariables($envs, $bouncerTarget);
2022-08-09 00:28:22 +00:00
if ($bouncerTarget->isPortSet()) {
$bouncerTarget->setEndpointHostnameOrIp($service['Spec']['Name']);
2024-01-05 14:47:37 +00:00
// $this->logger->info(sprintf('Ports for %s has been explicitly set to %s:%d.', $bouncerTarget->getName(), $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort()));
2022-08-09 00:28:22 +00:00
} elseif (isset($service['Endpoint']['Ports'])) {
$bouncerTarget->setEndpointHostnameOrIp('172.17.0.1');
$bouncerTarget->setPort($service['Endpoint']['Ports'][0]['PublishedPort']);
} else {
$this->logger->warning(sprintf('Ports block missing for %s.', $bouncerTarget->getName()));
continue;
}
$bouncerTarget->setTargetPath(sprintf('http://%s:%d/', $bouncerTarget->getEndpointHostnameOrIp(), $bouncerTarget->getPort()));
$bouncerTarget->setUseGlobalCert($this->isUseGlobalCert());
2022-05-05 14:42:53 +00:00
2022-06-15 09:22:24 +00:00
// $this->logger->debug(sprintf('Decided that %s has the target path %s', $bouncerTarget->getName(), $bouncerTarget->getTargetPath()));
2022-05-05 14:42:53 +00:00
2023-09-13 12:37:21 +00:00
if ($bouncerTarget->isEndpointValid()) {
2023-01-09 14:57:00 +00:00
$bouncerTargets[] = $bouncerTarget;
2024-01-05 14:47:37 +00:00
} else {
2023-09-13 12:37:21 +00:00
$this->logger->debug(sprintf(
2024-01-05 14:47:37 +00:00
'%s Decided that %s has the endpoint %s and it is not valid.',
Emoji::magnifyingGlassTiltedLeft(),
$bouncerTarget->getName(),
$bouncerTarget->getEndpointHostnameOrIp(),
2023-09-13 12:37:21 +00:00
));
2022-08-09 00:28:22 +00:00
}
2022-05-05 14:42:53 +00:00
}
}
}
2021-06-06 15:38:46 +00:00
return $bouncerTargets;
}
public function run(): void
{
2024-01-05 17:15:51 +00:00
$gitHash = substr($this->environment['GIT_SHA'], 0, 7);
$this->logger->info(sprintf('%s Starting Bouncer git=%s...', Emoji::CHARACTER_TIMER_CLOCK, $gitHash));
2022-06-15 09:22:24 +00:00
2021-06-06 22:08:49 +00:00
try {
$this->stateHasChanged();
2023-01-09 14:57:00 +00:00
} catch (ConnectException $connectException) {
2022-06-15 09:22:24 +00:00
$this->logger->critical(sprintf('%s Could not connect to docker socket! Did you map it?', Emoji::CHARACTER_CRYING_CAT));
2021-06-06 22:08:49 +00:00
exit;
}
2021-06-06 15:38:46 +00:00
while (true) {
$this->runLoop();
}
}
2022-06-15 09:22:24 +00:00
public function parseContainerEnvironmentVariables(array $envs, BouncerTarget $bouncerTarget): BouncerTarget
{
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;
2023-01-09 14:57:00 +00:00
case 'BOUNCER_AUTH':
2024-01-05 14:47:37 +00:00
[$username, $password] = explode(':', $eVal);
2023-01-09 14:57:00 +00:00
$bouncerTarget->setAuth($username, $password);
break;
2022-06-15 09:22:24 +00:00
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;
case 'BOUNCER_ALLOW_WEBSOCKETS':
$bouncerTarget->setAllowWebsocketSupport(in_array(strtolower($eVal), ['yes', 'true'], true));
2022-06-15 09:34:07 +00:00
break;
case 'BOUNCER_ALLOW_LARGE_PAYLOADS':
$bouncerTarget->setAllowLargePayloads(in_array(strtolower($eVal), ['yes', 'true'], true));
break;
2023-01-09 14:57:00 +00:00
case 'BOUNCER_PROXY_TIMEOUT_SECONDS':
$bouncerTarget->setProxyTimeoutSeconds(is_numeric($eVal) ? $eVal : null);
2022-06-15 09:22:24 +00:00
break;
}
}
return $bouncerTarget;
}
2024-01-05 14:47:37 +00:00
private function dockerGetContainers(): array
{
return json_decode($this->client->request('GET', 'containers/json')->getBody()->getContents(), true);
}
private function dockerGetContainer(string $id): array
{
return json_decode($this->client->request('GET', "containers/{$id}/json")->getBody()->getContents(), true);
}
2024-01-05 18:51:03 +00:00
private function dockerEnvFilter(?array $envs): array
{
if ($envs === null) {
return [];
}
return 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));
}
2021-06-06 15:38:46 +00:00
/**
* Returns true when something has changed.
*
* @throws \GuzzleHttp\Exception\GuzzleException
*/
private function stateHasChanged(): bool
{
2024-01-05 17:15:51 +00:00
$isTainted = false;
if ($this->lastUpdateEpoch === null) {
$isTainted = true;
} elseif ($this->forcedUpdateIntervalSeconds > 0 && $this->lastUpdateEpoch <= time() - $this->forcedUpdateIntervalSeconds) {
$this->logger->warning(sprintf('%s Forced update interval of %d seconds has been reached, forcing update.', Emoji::watch(), $this->forcedUpdateIntervalSeconds));
$isTainted = true;
} elseif ($this->previousContainerState === []) {
$this->logger->warning(sprintf('%s Initial state has not been set, forcing update.', Emoji::watch()));
$isTainted = true;
} elseif ($this->previousSwarmState === []) {
$this->logger->warning(sprintf('%s Initial swarm state has not been set, forcing update.', Emoji::watch()));
$isTainted = true;
2022-08-09 00:28:22 +00:00
}
2022-05-05 16:15:11 +00:00
// Standard Containers
2024-01-05 14:47:37 +00:00
$newContainerState = [];
2024-01-05 17:16:21 +00:00
$containers = $this->dockerGetContainers();
2021-06-06 15:38:46 +00:00
foreach ($containers as $container) {
2024-01-05 17:16:21 +00:00
$inspect = $this->dockerGetContainer($container['Id']);
$name = ltrim($inspect['Name'], '/');
2024-01-05 17:15:51 +00:00
$newContainerState[$name] = [
2024-01-05 17:16:21 +00:00
'name' => $name,
2024-01-05 17:15:51 +00:00
'created' => $inspect['Created'],
2024-01-05 17:16:21 +00:00
'image' => $inspect['Image'],
'status' => $inspect['State']['Status'],
2024-01-05 18:51:03 +00:00
'env' => $this->dockerEnvFilter($inspect['Config']['Env']),
2024-01-05 14:47:37 +00:00
];
2024-01-05 18:51:03 +00:00
if (is_array($newContainerState[$name]['env'])) {
sort($newContainerState[$name]['env']);
}
2024-01-05 14:47:37 +00:00
}
ksort($newContainerState);
// Calculate Container State Hash
2024-01-05 17:15:51 +00:00
$containerStateDiff = $this->diff($this->previousContainerState, $newContainerState);
if (!$isTainted && !empty($containerStateDiff)) {
$this->logger->warning(sprintf('%s Container state has changed', Emoji::warning()));
2024-01-05 18:51:03 +00:00
$this->logger->debug(sprintf("Changed state:\n%s", $containerStateDiff));
2024-01-05 17:15:51 +00:00
$isTainted = true;
2021-06-06 15:38:46 +00:00
}
2024-01-05 17:15:51 +00:00
$this->previousContainerState = $newContainerState;
2022-05-05 16:15:11 +00:00
// Swarm Services
2024-01-05 14:47:37 +00:00
$newSwarmState = [];
2022-06-29 13:37:35 +00:00
if ($this->isSwarmMode()) {
$services = json_decode($this->client->request('GET', 'services')->getBody()->getContents(), true);
if (isset($services['message'])) {
2024-01-05 14:47:37 +00:00
$this->logger->warning(sprintf('Something happened while interrogating services.. This node is not a swarm node, cannot have services: %s', $services['message']));
2022-06-29 13:37:35 +00:00
} else {
foreach ($services as $service) {
2024-01-05 18:51:03 +00:00
$env = $service['Spec']['TaskTemplate']['ContainerSpec']['Env'] ?? [];
$name = $service['Spec']['Name'];
$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),
2024-01-05 14:47:37 +00:00
];
2022-06-29 13:37:35 +00:00
}
2022-05-05 16:15:11 +00:00
}
}
2024-01-05 14:47:37 +00:00
ksort($newSwarmState);
2021-06-06 15:38:46 +00:00
2024-01-05 14:47:37 +00:00
// Calculate Swarm State Hash, if applicable
2024-01-05 17:15:51 +00:00
$swarmStateDiff = $this->diff($this->previousSwarmState, $newSwarmState);
if ($this->isSwarmMode() && !$isTainted && !empty($swarmStateDiff)) {
$this->logger->warning(sprintf('%s Swarm state has changed', Emoji::warning()));
2024-01-05 18:51:03 +00:00
$this->logger->debug(sprintf("Changed state:\n%s", $swarmStateDiff));
2024-01-05 17:15:51 +00:00
$isTainted = true;
2021-06-06 15:38:46 +00:00
}
2024-01-05 17:15:51 +00:00
$this->previousSwarmState = $newSwarmState;
2021-06-06 15:38:46 +00:00
2024-01-05 17:15:51 +00:00
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());
2021-06-06 15:38:46 +00:00
}
private function runLoop(): void
{
if ($this->s3Enabled()) {
$this->getCertificatesFromS3();
}
2022-06-29 13:37:35 +00:00
try {
$determineSwarmMode = json_decode($this->client->request('GET', 'swarm')->getBody()->getContents(), true);
$this->setSwarmMode(!isset($determineSwarmMode['message']));
2023-01-09 14:57:00 +00:00
} catch (ServerException $exception) {
2022-06-29 13:37:35 +00:00
$this->setSwarmMode(false);
2023-01-09 14:57:00 +00:00
} catch (ConnectException $exception) {
$this->logger->critical(sprintf('%s Unable to connect to docker socket!', Emoji::warning()));
$this->logger->critical($exception->getMessage());
exit(1);
2022-06-29 13:37:35 +00:00
}
2022-06-15 09:22:24 +00:00
$this->logger->info(sprintf('%s Swarm mode is %s.', Emoji::CHARACTER_HONEYBEE, $this->isSwarmMode() ? 'enabled' : 'disabled'));
2022-08-09 01:12:09 +00:00
2022-08-09 02:09:25 +00:00
$targets = array_values(
array_merge(
$this->findContainersContainerMode(),
$this->isSwarmMode() ? $this->findContainersSwarmMode() : []
)
2022-08-09 01:12:09 +00:00
);
2022-05-05 14:42:53 +00:00
2024-01-05 17:15:51 +00:00
// 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);
// Wipe configs and rebuild
2022-08-09 00:28:22 +00:00
$this->wipeNginxConfig();
2021-06-06 15:38:46 +00:00
$this->logger->info(sprintf('%s Found %d services with BOUNCER_DOMAIN set', Emoji::CHARACTER_MAGNIFYING_GLASS_TILTED_LEFT, count($targets)));
2023-01-09 14:57:00 +00:00
$this->generateNginxConfigs($targets);
2021-06-06 15:38:46 +00:00
$this->generateLetsEncryptCerts($targets);
if ($this->s3Enabled()) {
$this->writeCertificatesToS3();
}
$this->waitUntilContainerChange();
}
private function waitUntilContainerChange(): void
{
while ($this->stateHasChanged() === false) {
sleep(5);
}
2022-08-09 00:28:22 +00:00
$this->lastUpdateEpoch = time();
2021-06-06 15:38:46 +00:00
}
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()}";
2024-01-05 14:47:37 +00:00
if ($file->fileSize() == 0) {
$this->logger->warning(sprintf(' > Downloading %s to %s was skipped, because it was empty', $file->path(), $localPath));
2023-01-15 02:37:12 +00:00
continue;
}
2024-01-05 14:47:37 +00:00
$this->logger->debug(sprintf(' > Downloading %s to %s (%d bytes)', $file->path(), $localPath, $file->fileSize()));
2021-06-06 15:38:46 +00:00
$this->certificateStoreLocal->writeStream($localPath, $this->certificateStoreRemote->readStream($file->path()));
2024-01-05 14:47:37 +00:00
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())));
2023-02-07 14:26:44 +00:00
}
2021-06-06 15:38:46 +00:00
$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());
2021-06-06 22:08:49 +00:00
// Stupid dirty hack bullshit reee
2022-06-15 09:22:24 +00:00
for ($i = 1; $i <= 9; ++$i) {
$livePath = str_replace("{$i}.pem", '.pem', $livePath);
2021-06-06 22:08:49 +00:00
}
2024-01-05 14:47:37 +00:00
$this->logger->debug(sprintf(' > Mirroring %s to %s (%d bytes)', $newLocalCert->path(), $livePath, $newLocalCert->fileSize()));
2021-06-06 15:38:46 +00:00
$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
{
2023-02-07 14:26:44 +00:00
$this->logger->info(sprintf('%s Uploading Certificates to S3', Emoji::CHARACTER_UP_ARROW));
2021-06-06 15:38:46 +00:00
foreach ($this->certificateStoreLocal->listContents('/archive', true) as $file) {
/** @var FileAttributes $file */
if ($file->isFile()) {
$remotePath = str_replace('archive/', '', $file->path());
2024-01-05 14:47:37 +00:00
if ($file->fileSize() == 0) {
2023-01-15 02:37:12 +00:00
$this->logger->warning(sprintf(" > Skipping uploading {$file->path()}, file is garbage (empty)."));
} elseif (!$this->certificateStoreRemote->fileExists($remotePath) || $this->fileChanged($file->path())) {
2024-01-05 14:47:37 +00:00
$this->logger->debug(sprintf(' > Uploading %s (%d bytes)', $file->path(), $file->fileSize()));
2023-01-15 02:37:12 +00:00
$this->certificateStoreRemote->write($remotePath, $this->certificateStoreLocal->read($file->path()));
} else {
$this->logger->debug(sprintf(" > Skipping uploading {$file->path()}, file not changed."));
2021-06-06 15:38:46 +00:00
}
}
}
}
2023-01-09 14:57:00 +00:00
/**
2024-01-05 18:51:03 +00:00
* @var BouncerTarget[]
2023-01-09 14:57:00 +00:00
*/
private function generateNginxConfigs(array $targets): self
{
2024-01-05 17:15:51 +00:00
// get the length of the longest name...
2024-01-05 18:51:03 +00:00
$longestName = max(array_map(fn (BouncerTarget $target) => strlen($target->getPresentationDomain()), $targets));
2024-01-05 17:15:51 +00:00
2023-01-09 14:57:00 +00:00
foreach ($targets as $target) {
$this->generateNginxConfig($target);
if (count($targets) <= $this->getMaximumNginxConfigCreationNotices()) {
2024-01-05 17:15:51 +00:00
$this->logger->info(sprintf(
'%s Created Nginx config for %s',
Emoji::pencil(),
str_pad(
2024-01-05 18:51:03 +00:00
$target->getPresentationDomain(),
$longestName,
2024-01-05 17:15:51 +00:00
' ',
STR_PAD_LEFT
)
));
2023-01-09 14:57:00 +00:00
}
}
if (count($targets) > $this->getMaximumNginxConfigCreationNotices()) {
$this->logger->info(sprintf('%s More than %d Nginx configs generated.. Too many to show them all!', Emoji::pencil(), $this->getMaximumNginxConfigCreationNotices()));
}
$this->logger->info(sprintf('%s Created %d Nginx configs..', Emoji::pencil(), count($targets)));
return $this;
}
2021-06-06 15:38:46 +00:00
private function generateNginxConfig(BouncerTarget $target): self
{
2022-08-09 00:28:22 +00:00
$configData = $this->twig->render('NginxTemplate.twig', $target->__toArray());
2023-01-09 14:57:00 +00:00
$this->configFilesystem->write($target->getFileName(), $configData);
2024-01-05 14:47:37 +00:00
if ($target->hasAuth()) {
2023-01-09 14:57:00 +00:00
$this->configFilesystem->write($target->getAuthFileName(), $target->getAuthFileData());
}
2021-06-06 15:38:46 +00:00
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)) {
2023-02-07 14:26:44 +00:00
$dubious = false;
2024-01-05 14:47:37 +00:00
if ($this->certificateStoreLocal->fileSize($testAgeFile) == 0) {
2023-02-07 14:26:44 +00:00
// File is empty, check its age instead.
$timeRemainingSeconds = $this->certificateStoreLocal->lastModified($testAgeFile) - time();
2024-01-05 17:16:21 +00:00
$dubious = true;
2024-01-05 14:47:37 +00:00
} else {
2024-01-05 17:16:21 +00:00
$ssl = openssl_x509_parse($this->certificateStoreLocal->read($testAgeFile));
2023-02-07 14:26:44 +00:00
$timeRemainingSeconds = $ssl['validTo_time_t'] - time();
}
2021-06-06 15:38:46 +00:00
if ($timeRemainingSeconds > 2592000) {
$this->logger->info(sprintf(
2023-02-07 14:26:44 +00:00
'%s Skipping %s, certificate is %s for %d days',
2021-06-06 15:38:46 +00:00
Emoji::CHARACTER_PARTYING_FACE,
$target->getName(),
2024-01-05 14:47:37 +00:00
$dubious ? 'dubiously good' : 'still good',
2021-06-06 15:38:46 +00:00
round($timeRemainingSeconds / 86400)
));
2021-06-06 22:08:49 +00:00
$target->setUseTemporaryCert(false);
$this->generateNginxConfig($target);
2022-06-15 09:22:24 +00:00
2021-06-06 15:38:46 +00:00
continue;
}
}
2023-01-11 17:13:24 +00:00
// Start running shell commands...
2021-06-06 15:38:46 +00:00
$shell = new Exec();
2023-01-11 17:13:24 +00:00
// Disable nginx tweaks
2024-01-05 14:47:37 +00:00
$this->logger->debug('Moving nginx tweak file..');
$disableNginxTweaksCommand = (new CommandBuilder('mv'))
->addSubCommand('/etc/nginx/conf.d/tweak.conf')
->addSubCommand('/etc/nginx/conf.d/tweak.disabled')
;
2023-01-11 17:13:24 +00:00
$shell->run($disableNginxTweaksCommand);
// Generate letsencrypt cert
2021-06-06 15:38:46 +00:00
$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));
}
2023-01-11 17:13:24 +00:00
// Re-enable nginx tweaks
2024-01-05 14:47:37 +00:00
$this->logger->debug('Moving nginx tweak file back..');
$disableNginxTweaksCommand = (new CommandBuilder('mv'))
->addSubCommand('/etc/nginx/conf.d/tweak.disabled')
->addSubCommand('/etc/nginx/conf.d/tweak.conf')
;
2023-01-11 17:13:24 +00:00
$shell->run($disableNginxTweaksCommand);
2021-06-06 15:38:46 +00:00
$target->setUseTemporaryCert(false);
$this->generateNginxConfig($target);
}
$this->restartNginx();
return $this;
}
private function restartNginx(): void
{
2024-01-05 17:16:21 +00:00
$shell = new Exec();
2021-06-06 15:38:46 +00:00
$command = new CommandBuilder('/usr/sbin/nginx');
$command->addFlag('s', 'reload');
2022-05-05 23:31:23 +00:00
$this->logger->info(sprintf('%s Restarting nginx', Emoji::CHARACTER_TIMER_CLOCK));
2021-06-06 15:38:46 +00:00
$shell->run($command);
}
2022-08-09 00:28:22 +00:00
private function wipeNginxConfig(): void
{
$this->logger->debug('Purging existing config files ...');
foreach ($this->configFilesystem->listContents('') as $file) {
/** @var FileAttributes $file */
if ($file->isFile() && $file->path() != 'default' && $file->path() != 'default-ssl') {
$this->configFilesystem->delete($file->path());
// $this->logger->debug(sprintf(' > %s', $file->path()));
}
}
// $this->logger->debug('Purge complete!');
}
2021-06-06 15:38:46 +00:00
}
(new Bouncer())->run();