diff --git a/bouncer/Dockerfile b/bouncer/Dockerfile index 2c4c365..8a889f7 100644 --- a/bouncer/Dockerfile +++ b/bouncer/Dockerfile @@ -17,11 +17,15 @@ RUN apt-get -qq update && \ > /etc/apt/sources.list.d/nginx-stable.list' && \ # Add nginx key apt-key adv --keyserver keyserver.ubuntu.com --recv-keys C300EE8C && \ + # Update apt-get -qq update && \ + # Install Nginx, Certbot bits and apache2-utils for htpasswd generation apt-get -yqq install --no-install-recommends \ nginx \ python3-certbot-nginx \ + apache2-utils \ && \ + # Cleanup apt-get remove -yqq \ lsb-core \ cups-common \ @@ -44,6 +48,7 @@ COPY Nginx-tweak.conf /etc/nginx/conf.d/tweak.conf COPY NginxTemplate.twig /app/ # Disable daemonising in nginx RUN sed -i '1s;^;daemon off\;\n;' /etc/nginx/nginx.conf +RUN sed -i 's|include /etc/nginx/sites-enabled/*|include /etc/nginx/sites-enabled/*.conf|g' /etc/nginx/nginx.conf COPY bouncer /app COPY composer.* /app/ COPY public /app/public @@ -59,3 +64,6 @@ COPY ./test/public-web-a /app/public FROM benzine/php:nginx-8.1 as test-app-b COPY ./test/public-web-b /app/public +FROM benzine/php:nginx-8.1 as test-app-c +COPY ./test/public-web-c /app/public + diff --git a/bouncer/NginxTemplate.twig b/bouncer/NginxTemplate.twig index 64031e3..e791cea 100644 --- a/bouncer/NginxTemplate.twig +++ b/bouncer/NginxTemplate.twig @@ -3,10 +3,8 @@ server { listen 80; listen [::]:80; {% endif %} -{% if enableSSL %} listen 443 ssl; listen [::]:443 ssl; -{% endif %} server_name {{ domains|join(' ') }}; access_log /var/log/bouncer/{{ name }}.access.log; error_log /var/log/bouncer/{{ name }}.error.log; @@ -17,7 +15,7 @@ server { {% elseif useGlobalCert %} ssl_certificate /certs/global.crt; ssl_certificate_key /certs/global.key; -{% elseif useLetsEncrypt %} +{% else %} ssl_certificate /etc/letsencrypt/live/{{ name }}/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/{{ name }}/privkey.pem; {% endif %} @@ -34,6 +32,11 @@ server { proxy_set_header X-Forwarded-Proto https; proxy_set_header Host $host; +{% if hasAuth %} + auth_basic "closed site"; + auth_basic_user_file sites-enabled/{{ authFile }}; +{% endif %} + {% if allowWebsocketSupport %} # WebSocket support proxy_http_version 1.1; @@ -41,6 +44,12 @@ server { proxy_set_header Connection "upgrade"; {% endif %} +{% if proxyTimeoutSeconds %} + proxy_read_timeout {{ proxyTimeoutSeconds }}; + proxy_connect_timeout {{ proxyTimeoutSeconds }}; + proxy_send_timeout {{ proxyTimeoutSeconds }}; +{% endif %} + proxy_pass {{ targetPath }}; } } @@ -52,4 +61,4 @@ server { server_name {{ domains|join(' ') }}; return 301 https://$host$request_uri; } -{% endif %} \ No newline at end of file +{% endif %} diff --git a/bouncer/Readme.md b/bouncer/Readme.md index a27263c..48c47da 100644 --- a/bouncer/Readme.md +++ b/bouncer/Readme.md @@ -37,6 +37,7 @@ These environment variables need to be applied to the CONSUMING SERVICE and not | Key | Example | Behaviour | |--------------------------------|-------------------------------------------------------------------------|---------------------------------------------------------------------------------------------------------------| | BOUNCER_DOMAIN | "a.example.com" | The domain that should be directed to this container | +| BOUNCER_AUTH | "username:password" e.g "root:toor" | Add a HTTP BASIC auth requirement to this hostname. | | BOUNCER_LETSENCRYPT | Values are "yes" or "true", anything else is false | To enable, or disable Lets Encrypt service for this hostname | | BOUNCER_TARGET_PORT | 9000 | Explicitly define the port you want to hit the service on, in case of ambiguity | | BOUNCER_ALLOW_NON_SSL | Defaults to enabled. Values are "yes" or "true", anything else is false | Should HTTP only traffic be allowed to hit this service? If disabled, http traffic is forwarded towards https | @@ -45,4 +46,4 @@ These environment variables need to be applied to the CONSUMING SERVICE and not | BOUNCER_MAX_PAYLOADS_MEGABYTES | numbers | Size of max payload to allow, in megabytes. Requires BOUNCER_ALLOW_LARGE_PAYLOADS to be enabled | ## Security considerations -If you're putting this behind access control to the docker socket, it will need access to the /swarm /services and /containers endpoints of the docker api. \ No newline at end of file +If you're putting this behind access control to the docker socket, it will need access to the /swarm /services and /containers endpoints of the docker api. diff --git a/bouncer/bouncer b/bouncer/bouncer index 2447ca9..ef27a3a 100755 --- a/bouncer/bouncer +++ b/bouncer/bouncer @@ -7,6 +7,8 @@ use AdamBrett\ShellWrapper\Runners\Exec; use Aws\S3\S3Client; use Bramus\Monolog\Formatter\ColoredLineFormatter; 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; @@ -28,10 +30,12 @@ class BouncerTarget private bool $allowNonSSL = true; private bool $useTemporaryCert = true; private bool $useGlobalCert = false; - private bool $useLetsEncrypt = false; private bool $allowWebsocketSupport = true; private bool $allowLargePayloads = false; private int $maxPayloadSizeMegabytes = 256; + private ?int $proxyTimeoutSeconds = null; + private ?string $username = null; + private ?string $password = null; public function __construct( private Logger $logger @@ -48,18 +52,88 @@ class BouncerTarget 'targetPath' => $this->getTargetPath(), 'useTemporaryCert' => $this->isUseTemporaryCert(), 'useGlobalCert' => $this->isUseGlobalCert(), - 'useLetsEncrypt' => $this->isUseLetsEncrypt(), 'allowNonSSL' => $this->isAllowNonSSL(), - 'enableSSL' => $this->isEnableSSL(), 'allowWebsocketSupport' => $this->isAllowWebsocketSupport(), 'allowLargePayloads' => $this->isAllowLargePayloads(), 'maxPayloadSizeMegabytes' => $this->getMaxPayloadSizeMegabytes(), + 'proxyTimeoutSeconds' => $this->getProxyTimeoutSeconds(), + 'hasAuth' => $this->hasAuth(), + 'authFile' => $this->getAuthFileName(), ]; } - public function isEnableSSL(): bool + /** + * @return string|null + */ + public function getUsername(): ?string { - return $this->isUseLetsEncrypt() || $this->isUseGlobalCert() || $this->isUseLetsEncrypt(); + return $this->username; + } + + /** + * @param string + * @return BouncerTarget + */ + public function setUsername(string $username): BouncerTarget + { + $this->username = $username; + return $this; + } + + /** + * @return string|null + */ + public function getPassword(): ?string + { + return $this->password; + } + + /** + * @param string $password + * @return BouncerTarget + */ + public function setPassword(string $password): BouncerTarget + { + $this->password = $password; + return $this; + } + + public function setAuth(string $username, string $password): BouncerTarget + { + return $this->setUsername($username)->setPassword($password); + } + + public function hasAuth() : bool + { + return $this->username != null && $this->password != null; + } + + public function getFileName() : string + { + return "{$this->getName()}.conf"; + } + + public function getAuthFileName() : string + { + return "{$this->getName()}.secret"; + } + + public function getAuthFileData() : string + { + $output = shell_exec(sprintf("htpasswd -nibB -C10 %s %s",$this->getUsername(), $this->getPassword())); + return trim($output)."\n"; + } + + public function getProxyTimeoutSeconds(): ?int + { + return $this->proxyTimeoutSeconds; + } + + public function setProxyTimeoutSeconds(?int $proxyTimeoutSeconds): BouncerTarget + { + $this->proxyTimeoutSeconds = $proxyTimeoutSeconds; + + return $this; } public function isUseTemporaryCert(): bool @@ -74,18 +148,6 @@ class BouncerTarget return $this; } - public function isUseLetsEncrypt(): bool - { - return $this->useLetsEncrypt; - } - - public function setUseLetsEncrypt(bool $useLetsEncrypt): BouncerTarget - { - $this->useLetsEncrypt = $useLetsEncrypt; - - return $this; - } - public function isUseGlobalCert(): bool { return $this->useGlobalCert; @@ -96,7 +158,9 @@ class BouncerTarget $this->useGlobalCert = $useGlobalCert; // Global cert overrides temporary certs. - $this->setUseTemporaryCert(false); + if($useGlobalCert) { + $this->setUseTemporaryCert(false); + } return $this; } @@ -277,6 +341,7 @@ class Bouncer private bool $useGlobalCert = false; private int $forcedUpdateIntervalSeconds = 600; private ?int $lastUpdateEpoch = null; + private int $maximumNginxConfigCreationNotices = 15; public function __construct() { @@ -346,6 +411,26 @@ class Bouncer $this->logger->info(sprintf('%s Forced update interval is every %d seconds', Emoji::watch(), $this->getForcedUpdateIntervalSeconds())); } + /** + * @return int + */ + public function getMaximumNginxConfigCreationNotices(): int + { + return $this->maximumNginxConfigCreationNotices; + } + + /** + * @param int $maximumNginxConfigCreationNotices + * + * @return Bouncer + */ + public function setMaximumNginxConfigCreationNotices(int $maximumNginxConfigCreationNotices): Bouncer + { + $this->maximumNginxConfigCreationNotices = $maximumNginxConfigCreationNotices; + + return $this; + } + public function isSwarmMode(): bool { return $this->swarmMode; @@ -433,7 +518,7 @@ class Bouncer // $valid ? 'is valid' : 'is not valid' // )); if ($valid) { - $bouncerTargets[$bouncerTarget->getDomains()[0]] = $bouncerTarget; + $bouncerTargets[] = $bouncerTarget; } } } @@ -495,7 +580,7 @@ class Bouncer // $valid ? 'is valid' : 'is not valid' // )); if ($valid) { - $bouncerTargets[$bouncerTarget->getDomains()[0]] = $bouncerTarget; + $bouncerTargets[] = $bouncerTarget; } } } @@ -510,7 +595,7 @@ class Bouncer try { $this->stateHasChanged(); - } catch (\GuzzleHttp\Exception\ConnectException $connectException) { + } catch (ConnectException $connectException) { $this->logger->critical(sprintf('%s Could not connect to docker socket! Did you map it?', Emoji::CHARACTER_CRYING_CAT)); exit; @@ -533,9 +618,14 @@ class Bouncer break; + case 'BOUNCER_AUTH': + list($username, $password) = explode(":", $eVal); + $bouncerTarget->setAuth($username, $password); + + break; + case 'BOUNCER_LETSENCRYPT': $bouncerTarget->setLetsEncrypt(in_array(strtolower($eVal), ['yes', 'true'], true)); - $bouncerTarget->setUseLetsEncrypt(true); break; @@ -562,6 +652,11 @@ class Bouncer case 'BOUNCER_MAX_PAYLOADS_MEGABYTES': $bouncerTarget->setMaxPayloadSizeMegabytes(is_numeric($eVal) ? $eVal : 256); + break; + + case 'BOUNCER_PROXY_TIMEOUT_SECONDS': + $bouncerTarget->setProxyTimeoutSeconds(is_numeric($eVal) ? $eVal : null); + break; } } @@ -627,8 +722,13 @@ class Bouncer try { $determineSwarmMode = json_decode($this->client->request('GET', 'swarm')->getBody()->getContents(), true); $this->setSwarmMode(!isset($determineSwarmMode['message'])); - } catch (\GuzzleHttp\Exception\ServerException $exception) { + } catch (ServerException $exception) { $this->setSwarmMode(false); + } catch (ConnectException $exception) { + $this->logger->critical(sprintf('%s Unable to connect to docker socket!', Emoji::warning())); + $this->logger->critical($exception->getMessage()); + + exit(1); } $this->logger->info(sprintf('%s Swarm mode is %s.', Emoji::CHARACTER_HONEYBEE, $this->isSwarmMode() ? 'enabled' : 'disabled')); @@ -643,9 +743,7 @@ class Bouncer $this->wipeNginxConfig(); $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->generateNginxConfigs($targets); $this->generateLetsEncryptCerts($targets); if ($this->s3Enabled()) { $this->writeCertificatesToS3(); @@ -722,11 +820,34 @@ class Bouncer } } + /** + * @param BouncerTarget[] $target + * + * @return $this + */ + private function generateNginxConfigs(array $targets): self + { + foreach ($targets as $target) { + $this->generateNginxConfig($target); + if (count($targets) <= $this->getMaximumNginxConfigCreationNotices()) { + $this->logger->info(sprintf('%s Created Nginx config for http://%s', Emoji::pencil(), $target->getName())); + } + } + 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; + } + private function generateNginxConfig(BouncerTarget $target): self { $configData = $this->twig->render('NginxTemplate.twig', $target->__toArray()); - $this->configFilesystem->write($target->getName(), $configData); - $this->logger->info(sprintf('%s Created Nginx config for http://%s', Emoji::CHARACTER_PENCIL, $target->getName())); + $this->configFilesystem->write($target->getFileName(), $configData); + if($target->hasAuth()){ + $this->configFilesystem->write($target->getAuthFileName(), $target->getAuthFileData()); + } return $this; }