From 66857914277b1187e861f661e716f280973b5e80 Mon Sep 17 00:00:00 2001 From: Simon L Date: Thu, 24 Aug 2023 14:09:21 +0200 Subject: [PATCH] add docker-socket-proxy as option Signed-off-by: Simon L --- .github/dependabot.yml | 9 +++ Containers/docker-socket-proxy/Dockerfile | 39 ++++++++++ Containers/docker-socket-proxy/haproxy.cfg | 72 +++++++++++++++++++ Containers/nextcloud/entrypoint.sh | 19 +++++ manual-install/update-yaml.sh | 2 + php/containers.json | 29 +++++++- php/public/disable-docker-socket-proxy.js | 4 ++ php/public/index.php | 1 + php/public/options-form-submit.js | 12 ++++ php/src/ContainerDefinitionFetcher.php | 8 +++ .../Controller/ConfigurationController.php | 5 ++ php/src/Data/ConfigurationManager.php | 15 ++++ php/src/Docker/DockerActionManager.php | 23 ++++-- php/templates/containers.twig | 5 ++ 14 files changed, 236 insertions(+), 7 deletions(-) create mode 100644 Containers/docker-socket-proxy/Dockerfile create mode 100644 Containers/docker-socket-proxy/haproxy.cfg create mode 100644 php/public/disable-docker-socket-proxy.js diff --git a/.github/dependabot.yml b/.github/dependabot.yml index f39c749a..5beec9d0 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -165,3 +165,12 @@ updates: labels: - 3. to review - dependencies +- package-ecosystem: "docker" + directory: "/Containers/docker-socket-proxy" + schedule: + interval: "daily" + time: "12:00" + open-pull-requests-limit: 10 + labels: + - 3. to review + - dependencies diff --git a/Containers/docker-socket-proxy/Dockerfile b/Containers/docker-socket-proxy/Dockerfile new file mode 100644 index 00000000..b9b5ef87 --- /dev/null +++ b/Containers/docker-socket-proxy/Dockerfile @@ -0,0 +1,39 @@ +# Inspiration: https://github.com/Tecnativa/docker-socket-proxy/blob/master/Dockerfile +FROM haproxy:2.4.24-alpine3.18 + +RUN set -ex; \ + apk add --no-cache date; \ + chmod 777 -R /run/; \ + chmod 777 -R /var/lib/haproxy + +EXPOSE 2375 +ENV ALLOW_RESTARTS=1 \ + AUTH=1 \ + BUILD=0 \ + COMMIT=0 \ + CONFIGS=0 \ + CONTAINERS=1 \ + DISTRIBUTION=0 \ + EVENTS=1 \ + EXEC=0 \ + GRPC=0 \ + IMAGES=1 \ + INFO=1 \ + LOG_LEVEL=info \ + NETWORKS=1 \ + NODES=0 \ + PING=1 \ + PLUGINS=0 \ + POST=0 \ + SECRETS=0 \ + SERVICES=1 \ + SESSION=0 \ + SOCKET_PATH=/var/run/docker.sock \ + SWARM=0 \ + SYSTEM=0 \ + TASKS=0 \ + VERSION=1 \ + VOLUMES=1 +COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg + +USER haproxy:root diff --git a/Containers/docker-socket-proxy/haproxy.cfg b/Containers/docker-socket-proxy/haproxy.cfg new file mode 100644 index 00000000..4eae253d --- /dev/null +++ b/Containers/docker-socket-proxy/haproxy.cfg @@ -0,0 +1,72 @@ +# Inspiration: https://github.com/Tecnativa/docker-socket-proxy/blob/master/haproxy.cfg + +global + log stdout format raw daemon "${LOG_LEVEL}" + + pidfile /run/haproxy.pid + maxconn 4000 + + # Turn on stats unix socket + server-state-file /var/lib/haproxy/server-state + +defaults + mode http + log global + option httplog + option dontlognull + option http-server-close + option redispatch + retries 3 + timeout http-request 10s + timeout queue 1m + timeout connect 10s + timeout client 10m + timeout server 10m + timeout http-keep-alive 10s + timeout check 10s + maxconn 3000 + + # Allow seamless reloads + load-server-state-from-file global + + # Use provided example error pages + errorfile 400 /usr/local/etc/haproxy/errors/400.http + errorfile 403 /usr/local/etc/haproxy/errors/403.http + errorfile 408 /usr/local/etc/haproxy/errors/408.http + errorfile 500 /usr/local/etc/haproxy/errors/500.http + errorfile 502 /usr/local/etc/haproxy/errors/502.http + errorfile 503 /usr/local/etc/haproxy/errors/503.http + errorfile 504 /usr/local/etc/haproxy/errors/504.http + +backend dockerbackend + server dockersocket $SOCKET_PATH + +frontend dockerfrontend + bind :2375 + http-request deny unless METH_GET || { env(POST) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/containers/[a-zA-Z0-9_.-]+/((stop)|(restart)|(kill)) } { env(ALLOW_RESTARTS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/auth } { env(AUTH) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/build } { env(BUILD) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/commit } { env(COMMIT) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/configs } { env(CONFIGS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/containers } { env(CONTAINERS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/distribution } { env(DISTRIBUTION) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/events } { env(EVENTS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/exec } { env(EXEC) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/grpc } { env(GRPC) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/images } { env(IMAGES) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/info } { env(INFO) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/networks } { env(NETWORKS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/nodes } { env(NODES) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/_ping } { env(PING) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/plugins } { env(PLUGINS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/secrets } { env(SECRETS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/services } { env(SERVICES) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/session } { env(SESSION) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/swarm } { env(SWARM) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/system } { env(SYSTEM) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/tasks } { env(TASKS) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/version } { env(VERSION) -m bool } + http-request allow if { path,url_dec -m reg -i ^(/v[\d\.]+)?/volumes } { env(VOLUMES) -m bool } + http-request deny + default_backend dockerbackend diff --git a/Containers/nextcloud/entrypoint.sh b/Containers/nextcloud/entrypoint.sh index 5db7d152..980f8a56 100644 --- a/Containers/nextcloud/entrypoint.sh +++ b/Containers/nextcloud/entrypoint.sh @@ -282,6 +282,8 @@ DATADIR_PERMISSION_CONF touch "$NEXTCLOUD_DATA_DIR/install.failed" exit 1 fi + # shellcheck disable=SC2016 + installed_version="$(php -r 'require "/var/www/html/version.php"; echo implode(".", $OC_Version);')" fi php /var/www/html/occ app:disable updatenotification rm -rf /var/www/html/apps/updatenotification @@ -732,5 +734,22 @@ else fi fi +# Docker socket proxy +if version_greater "$installed_version" "28.0.0.0"; then + if [ "$DOCKER_SOCKET_PROXY_ENABLED" = 'yes' ]; then + if ! [ -d "/var/www/html/custom_apps/app_ecosystem_v2" ]; then + php /var/www/html/occ app:install app_ecosystem_v2 + elif [ "$(php /var/www/html/occ config:app:get app_ecosystem_v2 enabled)" != "yes" ]; then + php /var/www/html/occ app:enable app_ecosystem_v2 + elif [ "$SKIP_UPDATE" != 1 ]; then + php /var/www/html/occ app:update app_ecosystem_v2 + fi + else + if [ -d "/var/www/html/custom_apps/app_ecosystem_v2" ]; then + php /var/www/html/occ app:remove app_ecosystem_v2 + fi + fi +fi + # Remove the update skip file always rm -f "$NEXTCLOUD_DATA_DIR"/skip.update diff --git a/manual-install/update-yaml.sh b/manual-install/update-yaml.sh index 39ac7c37..055466e0 100644 --- a/manual-install/update-yaml.sh +++ b/manual-install/update-yaml.sh @@ -20,6 +20,7 @@ OUTPUT="$(echo "$OUTPUT" | jq 'del(.services[].nextcloud_exec_commands)')" OUTPUT="$(echo "$OUTPUT" | jq 'del(.services[] | select(.container_name == "nextcloud-aio-watchtower"))')" OUTPUT="$(echo "$OUTPUT" | jq 'del(.services[] | select(.container_name == "nextcloud-aio-domaincheck"))')" OUTPUT="$(echo "$OUTPUT" | jq 'del(.services[] | select(.container_name == "nextcloud-aio-borgbackup"))')" +OUTPUT="$(echo "$OUTPUT" | jq 'del(.services[] | select(.container_name == "nextcloud-aio-docker-socket-proxy"))')" OUTPUT="$(echo "$OUTPUT" | jq '.services[] |= if has("depends_on") then .depends_on |= map({ (.): { "condition": "service_started", "required": false } }) else . end' | jq '.services[] |= if has("depends_on") then .depends_on |= reduce .[] as $item ({}; . + $item) else . end')" snap install yq @@ -66,6 +67,7 @@ do sed -i "s|$variable|\${$sole_variable}|g" containers.yml done +sed -i '/DOCKER_SOCKET_PROXY_ENABLED/d' sample.conf sed -i 's|_ENABLED=|_ENABLED="no" # Setting this to "yes" (with quotes) enables the option in Nextcloud automatically.|' sample.conf sed -i 's|CLAMAV_ENABLED=no.*|CLAMAV_ENABLED="no" # Setting this to "yes" (with quotes) enables the option in Nextcloud automatically. Note: arm64 has no clamav support|' sample.conf sed -i 's|TALK_ENABLED=no|TALK_ENABLED="yes"|' sample.conf diff --git a/php/containers.json b/php/containers.json index c3689f6f..a380e355 100644 --- a/php/containers.json +++ b/php/containers.json @@ -120,7 +120,8 @@ "nextcloud-aio-clamav", "nextcloud-aio-fulltextsearch", "nextcloud-aio-talk-recording", - "nextcloud-aio-imaginary" + "nextcloud-aio-imaginary", + "nextcloud-aio-docker-socket-proxy" ], "display_name": "Nextcloud", "image": "nextcloud/aio-nextcloud", @@ -203,7 +204,8 @@ "TALK_RECORDING_ENABLED=%TALK_RECORDING_ENABLED%", "RECORDING_SECRET=%RECORDING_SECRET%", "TALK_RECORDING_HOST=nextcloud-aio-talk-recording", - "FULLTEXTSEARCH_PASSWORD=%FULLTEXTSEARCH_PASSWORD%" + "FULLTEXTSEARCH_PASSWORD=%FULLTEXTSEARCH_PASSWORD%", + "DOCKER_SOCKET_PROXY_ENABLED=%DOCKER_SOCKET_PROXY_ENABLED%" ], "restart": "unless-stopped", "devices": [ @@ -639,6 +641,29 @@ "secrets": [ "FULLTEXTSEARCH_PASSWORD" ] + }, + { + "container_name": "nextcloud-aio-docker-socket-proxy", + "display_name": "Docker Socket Proxy", + "image": "nextcloud/aio-docker-socket-proxy", + "init": true, + "internal_port": "2375", + "environment": [ + "TZ=%TIMEZONE%" + ], + "volumes": [ + { + "source": "%WATCHTOWER_DOCKER_SOCKET_PATH%", + "destination": "/var/run/docker.sock", + "writeable": false + } + ], + "restart": "unless-stopped", + "read_only": true, + "tmpfs": [ + "/run/", + "/var/lib/haproxy" + ] } ] } diff --git a/php/public/disable-docker-socket-proxy.js b/php/public/disable-docker-socket-proxy.js new file mode 100644 index 00000000..79099423 --- /dev/null +++ b/php/public/disable-docker-socket-proxy.js @@ -0,0 +1,4 @@ +document.addEventListener("DOMContentLoaded", function(event) { + // Docker socket proxy + document.getElementById("docker-socket-proxy").disabled = true; +}); diff --git a/php/public/index.php b/php/public/index.php index de46eb41..7397782e 100644 --- a/php/public/index.php +++ b/php/public/index.php @@ -121,6 +121,7 @@ $app->get('/containers', function (Request $request, Response $response, array $ 'nextcloud_memory_limit' => $configurationManager->GetNextcloudMemoryLimit(), 'is_dri_device_enabled' => $configurationManager->isDriDeviceEnabled(), 'is_talk_recording_enabled' => $configurationManager->isTalkRecordingEnabled(), + 'is_docker_socket_proxy_enabled' => $configurationManager->isDockerSocketProxyEnabled(), ]); })->setName('profile'); $app->get('/login', function (Request $request, Response $response, array $args) use ($container) { diff --git a/php/public/options-form-submit.js b/php/public/options-form-submit.js index cbd55fba..a8a4411d 100644 --- a/php/public/options-form-submit.js +++ b/php/public/options-form-submit.js @@ -14,6 +14,13 @@ function handleTalkVisibility() { } } +function handleDockerSocketProxyWarning() { + let dockerSocketProxy = document.getElementById("docker-socket-proxy"); + if (dockerSocketProxy.checked) { + alert('⚠️ Warning! Enabling this container comes with possible Security problems since you are exposing the docker socket and all its privileges to the Nextcloud container. Enable this only if you are sure what you are doing!') + } +} + document.addEventListener("DOMContentLoaded", function(event) { // handle submit button for options form let optionsFormSubmit = document.getElementById("options-form-submit"); @@ -52,4 +59,9 @@ document.addEventListener("DOMContentLoaded", function(event) { // Fulltextsearch let fulltextsearch = document.getElementById("fulltextsearch"); fulltextsearch.addEventListener('change', makeOptionsFormSubmitVisible); + + // Docker socket proxy + let dockerSocketProxy = document.getElementById("docker-socket-proxy"); + dockerSocketProxy.addEventListener('change', makeOptionsFormSubmitVisible); + dockerSocketProxy.addEventListener('change', handleDockerSocketProxyWarning); }); diff --git a/php/src/ContainerDefinitionFetcher.php b/php/src/ContainerDefinitionFetcher.php index 14cd9c25..3438b889 100644 --- a/php/src/ContainerDefinitionFetcher.php +++ b/php/src/ContainerDefinitionFetcher.php @@ -93,6 +93,10 @@ class ContainerDefinitionFetcher if (!$this->configurationManager->isFulltextsearchEnabled()) { continue; } + } elseif ($entry['container_name'] === 'nextcloud-aio-docker-socket-proxy') { + if (!$this->configurationManager->isDockerSocketProxyEnabled()) { + continue; + } } $ports = new ContainerPorts(); @@ -195,6 +199,10 @@ class ContainerDefinitionFetcher if (!$this->configurationManager->isFulltextsearchEnabled()) { continue; } + } elseif ($value === 'nextcloud-aio-docker-socket-proxy') { + if (!$this->configurationManager->isDockerSocketProxyEnabled()) { + continue; + } } $dependsOn[] = $value; } diff --git a/php/src/Controller/ConfigurationController.php b/php/src/Controller/ConfigurationController.php index 1dbb20f5..b7271398 100644 --- a/php/src/Controller/ConfigurationController.php +++ b/php/src/Controller/ConfigurationController.php @@ -110,6 +110,11 @@ class ConfigurationController } else { $this->configurationManager->SetFulltextsearchEnabledState(0); } + if (isset($request->getParsedBody()['docker-socket-proxy'])) { + $this->configurationManager->SetDockerSocketProxyEnabledState(1); + } else { + $this->configurationManager->SetDockerSocketProxyEnabledState(0); + } } if (isset($request->getParsedBody()['delete_collabora_dictionaries'])) { diff --git a/php/src/Data/ConfigurationManager.php b/php/src/Data/ConfigurationManager.php index 9bb6fbc9..86a04d5e 100644 --- a/php/src/Data/ConfigurationManager.php +++ b/php/src/Data/ConfigurationManager.php @@ -149,6 +149,21 @@ class ConfigurationManager } } + public function isDockerSocketProxyEnabled() : bool { + $config = $this->GetConfig(); + if (isset($config['isDockerSocketProxyEnabled']) && $config['isDockerSocketProxyEnabled'] === 1) { + return true; + } else { + return false; + } + } + + public function SetDockerSocketProxyEnabledState(int $value) : void { + $config = $this->GetConfig(); + $config['isDockerSocketProxyEnabled'] = $value; + $this->WriteConfig($config); + } + public function SetClamavEnabledState(int $value) : void { $config = $this->GetConfig(); $config['isClamavEnabled'] = $value; diff --git a/php/src/Docker/DockerActionManager.php b/php/src/Docker/DockerActionManager.php index 3a3d9c61..0e9228d6 100644 --- a/php/src/Docker/DockerActionManager.php +++ b/php/src/Docker/DockerActionManager.php @@ -346,6 +346,12 @@ class DockerActionManager } else { $replacements[1] = ''; } + } elseif ($out[1] === 'DOCKER_SOCKET_PROXY_ENABLED') { + if ($this->configurationManager->isDockerSocketProxyEnabled()) { + $replacements[1] = 'yes'; + } else { + $replacements[1] = ''; + } } elseif ($out[1] === 'NEXTCLOUD_UPLOAD_LIMIT') { $replacements[1] = $this->configurationManager->GetNextcloudUploadLimit(); } elseif ($out[1] === 'NEXTCLOUD_MEMORY_LIMIT') { @@ -406,7 +412,11 @@ class DockerActionManager $portWithProtocol = $value->port . '/' . $value->protocol; $exposedPorts[$portWithProtocol] = null; } - $requestBody['HostConfig']['NetworkMode'] = 'nextcloud-aio'; + if ($container->GetIdentifier() !== 'nextcloud-aio-docker-socket-proxy') { + $requestBody['HostConfig']['NetworkMode'] = 'nextcloud-aio'; + } else { + $requestBody['HostConfig']['NetworkMode'] = 'nextcloud-aio-docker-socket-proxy-network'; + } } else { $requestBody['HostConfig']['NetworkMode'] = 'host'; } @@ -763,13 +773,12 @@ class DockerActionManager } } - private function ConnectContainerIdToNetwork(string $id, string $internalPort) : void + private function ConnectContainerIdToNetwork(string $id, string $internalPort, string $network = 'nextcloud-aio') : void { if ($internalPort === 'host') { return; } - $network = 'nextcloud-aio'; $url = $this->BuildApiUrl('networks/create'); try { $this->guzzleClient->request( @@ -777,7 +786,7 @@ class DockerActionManager $url, [ 'json' => [ - 'Name' => 'nextcloud-aio', + 'Name' => $network, 'CheckDuplicate' => true, 'Driver' => 'bridge', 'Internal' => false, @@ -815,13 +824,17 @@ class DockerActionManager public function ConnectMasterContainerToNetwork() : void { $this->ConnectContainerIdToNetwork('nextcloud-aio-mastercontainer', ''); + $this->ConnectContainerIdToNetwork('nextcloud-aio-mastercontainer', '', 'nextcloud-aio-docker-socket-proxy-network'); // Don't disconnect here since it slows down the initial login by a lot. Is getting done during cron.sh instead. // $this->DisconnectContainerFromBridgeNetwork('nextcloud-aio-mastercontainer'); } public function ConnectContainerToNetwork(Container $container) : void { - $this->ConnectContainerIdToNetwork($container->GetIdentifier(), $container->GetInternalPort()); + $this->ConnectContainerIdToNetwork($container->GetIdentifier(), $container->GetInternalPort()); + if ($container->GetIdentifier() === 'nextcloud-aio-nextcloud' || $container->GetIdentifier() === 'nextcloud-aio-docker-socket-proxy') { + $this->ConnectContainerIdToNetwork($container->GetIdentifier(), $container->GetInternalPort(), 'nextcloud-aio-docker-socket-proxy-network'); + } } public function StopContainer(Container $container) : void { diff --git a/php/templates/containers.twig b/php/templates/containers.twig index e47baff4..0eab3f36 100644 --- a/php/templates/containers.twig +++ b/php/templates/containers.twig @@ -574,6 +574,11 @@ {% else %} {#
#} {% endif %} + {% if is_docker_socket_proxy_enabled == true %} +

+ {% else %} +

+ {% endif %}