489 lines
16 KiB
Text
489 lines
16 KiB
Text
|
|
#!/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();
|