Skip to content

Commit

Permalink
feat: Support read-only root filesystem
Browse files Browse the repository at this point in the history
  • Loading branch information
PSanetra committed Aug 6, 2024
1 parent cc26bb9 commit a776964
Show file tree
Hide file tree
Showing 8 changed files with 78 additions and 23 deletions.
11 changes: 11 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,17 @@ special_host:

With this configuration the application would have the app title "My Special Application", when it is accessed via the host `special.example.com`, while the endpoints would stay the same in every instance of the application.

## Read-only Root Filesystem Support

It is recommended to use a read-only root filesystem when running containers. However, the following directories must remain writable when using this base image:

* `/config/.out`
* This base image generates files in this directory at startup.
* `/tmp`
* Nginx uses this directory to manage cached files and the nginx.pid file. For more information, see [nginxinc/docker-nginx-unprivileged#troubleshooting-tips](https://github.com/nginxinc/docker-nginx-unprivileged/tree/af6e325d35e6833af9cdda8493866b88649e8aaf?tab=readme-ov-file#troubleshooting-tips).

It is possible to mount these directories as writable volumes. When using Kubernetes, one solution is to mount `emptyDir` volumes at these mount points.

## Development

Configuration files are dynamically generated via [gomplate templates](https://docs.gomplate.ca/).
Expand Down
3 changes: 2 additions & 1 deletion config/bootstrap.sh
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ else
CONFIG_FILES="merge:${CONFIG_FILES}|file://${CONFIG_DIR}/.internal_default.yaml"
fi

rm -rf "${CONFIG_DIR}/.out"
rm -rf "${CONFIG_DIR}/.out/*"
mkdir -p "${CONFIG_DIR}/.out/www"

cd "${CONFIG_DIR}"

Expand Down
2 changes: 1 addition & 1 deletion config/main.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -10,5 +10,5 @@
{{- range $key, $server := $effectiveConfig -}}
{{- $spa_config_hash := tmpl.Exec "write-spa_config" $server -}}
{{- $new_index := tmpl.Exec "write-index" (dict "path" "index.html" "base_href" $server.base_href "spa_config_hash" $spa_config_hash ) -}}
{{- template "write-nginx-server-conf" (dict "name" $key "config" (merge (dict "index" $new_index) $server)) -}}
{{- template "write-nginx-server-conf" (dict "name" $key "config" (merge (dict "index" $new_index "spa_config_hash" $spa_config_hash) $server)) -}}
{{ end -}}
48 changes: 40 additions & 8 deletions config/templates/server.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ server {

{{ end -}}
server {
{{- $config_dir := env.Getenv "CONFIG_DIR" | test.Required "CONFIG_DIR is not defined" -}}
{{- $out_www_dir := (filepath.Join $config_dir ".out" "www") -}}

{{- if and .http.enabled (not (and .https.enabled .http.https_redirect)) }}
listen {{ .http.port }}{{ if .http.http2_enabled }} http2{{ end }}{{ if .is_default }} default_server{{ end }};
{{- end }}
Expand All @@ -41,11 +44,46 @@ server {

keepalive_timeout {{ .keepalive.server.timeout_seconds }}s;

root {{ $app_root }};

{{ tmpl.Exec "access_log" .access_log }}

{{ tmpl.Exec "server-hardening" . | strings.Indent 4 " " }}
{{ tmpl.Exec "general-security-headers" . | strings.Indent 4 " " }}

location = /spa_config.js {
return 404;
}

location = /spaConfig.js {
return 404;
}

location = /SpaConfig.js {
return 404;
}

location = /spa_config.{{ .spa_config_hash }}.js {
root {{ $out_www_dir }};
etag off;
add_header Cache-Control "public, max-age=31536000, immutable";
{{ tmpl.Exec "web-document-hardening" . | strings.Indent 8 " " }}
}

location = /index.html {
add_header Cache-Control "no-cache, max-age=0";
{{ tmpl.Exec "web-document-hardening" . | strings.Indent 8 " " }}

return 200 /{{ .index }};
}

location = /{{- .index }} {
add_header Cache-Control "no-cache, max-age=0";
{{ tmpl.Exec "web-document-hardening" . | strings.Indent 8 " " }}

root {{ $out_www_dir }};
}

error_page 500 502 503 504 /50x.html;
location = /50x.html {
root /usr/share/nginx/html;
Expand All @@ -60,41 +98,35 @@ server {
# Prevent server requests on hashed resources
# We ignore html files with hashes as they might got modified
location ~* "\.[a-f0-9]{8,}(\.chunk)?\.(css|ico|pdf|flv|jpg|jpeg|png|gif|svg|ttf|otf|eot|woff|woff2|swf|map)$" {
root {{ $app_root }};
etag off;
add_header Cache-Control "public, max-age=31536000, immutable";
{{ tmpl.Exec "general-security-headers" . | strings.Indent 8 " " }}
}

location ~* "\.[a-f0-9]{8,}(\.chunk)?\.js$" {
root {{ $app_root }};
etag off;
add_header Cache-Control "public, max-age=31536000, immutable";
{{ tmpl.Exec "web-document-hardening" . | strings.Indent 8 " " }}
}

# Return resource or 404 if resource could not be found
location ~* "\.(css|ico|pdf|flv|jpg|jpeg|png|gif|svg|ttf|otf|eot|woff|woff2|swf|map)$" {
root {{ $app_root }};
add_header Cache-Control "no-cache, max-age=0";
{{ tmpl.Exec "general-security-headers" . | strings.Indent 8 " " }}
}

# Return resource or 404 if explicitly requested HTML or JavaScript document could not be found
location ~* "\.(js|html|htm)$" {
root {{ $app_root }};
add_header Cache-Control "no-cache, max-age=0";
{{ tmpl.Exec "web-document-hardening" . | strings.Indent 8 " " }}
}

# Return index.html on other requests by default
# Return index on other requests by default
location / {
root {{ $app_root }};
add_header Cache-Control "no-cache, max-age=0";
{{ tmpl.Exec "web-document-hardening" . | strings.Indent 8 " " }}

# Return index.html if it exists (does not exist in root as we delete it)
index index.html;
index {{ .index -}};
try_files $uri /{{- .index -}};
}

Expand Down
2 changes: 1 addition & 1 deletion config/templates/write_index.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,6 @@

{{- $file_hash := ($file_content | crypto.SHA1) -}}
{{- $new_path := .path | regexp.ReplaceLiteral "\\.htm(l)?$" (print "." $file_hash ".html") -}}
{{- $file_content | file.Write (filepath.Join $config_dir ".out" $new_path) -}}
{{- $file_content | file.Write (filepath.Join $config_dir ".out" "www" $new_path) -}}
{{- $new_path -}}
{{- end -}}
2 changes: 1 addition & 1 deletion config/templates/write_spa_config.js.tmpl
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ var spaConfig = {{ . | toJSON }}
{{- $config_dir := env.Getenv "CONFIG_DIR" | test.Required "CONFIG_DIR is not defined" -}}
{{- $file_content := (tmpl.Exec "spa_config" .spa_config) -}}
{{- $file_hash := ($file_content | crypto.SHA1) -}}
{{- $file_path := (filepath.Join $config_dir (print ".out/spa_config." $file_hash ".js")) -}}
{{- $file_path := (filepath.Join $config_dir ".out" "www" (print "spa_config." $file_hash ".js")) -}}
{{- tmpl.Exec "spa_config" .spa_config | file.Write $file_path -}}
{{- $file_hash -}}
{{- end -}}
11 changes: 0 additions & 11 deletions docker-entrypoint.sh
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,4 @@ test -n "${CONFIG_DIR}" || ( echo 'CONFIG_DIR is not defined!' && false )

${CONFIG_DIR}/bootstrap.sh

find "${APP_ROOT}" -maxdepth 1 -iname 'spa_config.js' -exec rm -f {} \;
find "${APP_ROOT}" -maxdepth 1 -iname 'spa_config.*.js' -exec rm -f {} \;
find "${APP_ROOT}" -maxdepth 1 -iname 'spaConfig.js' -exec rm -f {} \;
find "${APP_ROOT}" -maxdepth 1 -iname 'spaConfig.*.js' -exec rm -f {} \;

find "${CONFIG_DIR}/.out" -name "spa_config.*.js" -exec cp {} ${APP_ROOT}/ \;
find "${CONFIG_DIR}/.out" -name "index.*.html" -exec cp {} ${APP_ROOT}/ \;

# Delete existing index in root directory so that each listening server can serve its custom index as default document
rm -f "${APP_ROOT}/index.html"

exec nginx -g "daemon off;"
22 changes: 22 additions & 0 deletions tests/src/test/java/de/codecentric/spa/server/tests/MiscTests.java
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,10 @@
import de.codecentric.spa.server.tests.containers.SpaServerContainer;
import org.junit.jupiter.api.Test;
import org.testcontainers.containers.Network;
import org.testcontainers.shaded.com.google.common.collect.ImmutableMap;

import java.util.HashMap;
import java.util.Objects;

import static de.codecentric.spa.server.tests.containers.Curl.curl;
import static org.assertj.core.api.Assertions.assertThat;
Expand All @@ -23,4 +27,22 @@ public void shouldStartWithTtyAndStdin() {
}
}

@Test
public void shouldSupportReadOnlyFileSystemWithConfigOutVolume() {
try (
var network = Network.newNetwork();
var container = new SpaServerContainer()
.withCreateContainerCmdModifier(cmd -> Objects.requireNonNull(cmd.getHostConfig()).withReadonlyRootfs(true))
.withNetwork(network)
.withNetworkAliases("testcontainer")
.withTmpFs(ImmutableMap.of(
"/config/.out", "rw",
"/tmp", "rw"
))) {
container.start();

assertThat(curl(network, "curl", "http://testcontainer")).contains("<base href=\"/\" />");
}
}

}

0 comments on commit a776964

Please sign in to comment.