From 465de6cc71ac09314ccd713e69b09dcbf30a1f8f Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 16:36:45 -0400 Subject: [PATCH 1/3] refactor: consolidate nginx.conf.sigil server blocks The default nginx template rendered four near-identical server blocks per app, so the same listen, access_log, error_log, ssl_*, error_page, and proxy chain had to be maintained across http, https, grpc, and grpcs branches. Merging http and https into a single branch keyed on an `is_ssl` boolean, and likewise grpc and grpcs, removes the duplicate proxy_set_header chain and error-page locations and brings the structure in line with the openresty proxy template. Output is preserved up to whitespace and the existing `cat -s` pass already squashes the leftover blank lines. Adds plugins/nginx-vhosts/template_test.go exercising the rendered output via sigil as a Go library across HTTP-only, HTTPS, HTTP-to-HTTPS redirect, no-listeners 502 fallback, gRPC, gRPCs, gRPC-without-listeners skip, IPv4 bind, upstream blocks with and without keepalive, the access_log `off` short-circuit, the X-Forwarded-Ssl toggle, the http2 listen-parameter vs directive split, http2_push_preload conditional emission, and the nginx.conf.d/*.conf include in every code path; and tests/unit/nginx-vhosts_16.bats covering deploy plus `nginx -t` end-to-end for both HTTP and HTTPS apps. --- plugins/nginx-vhosts/go.mod | 8 + plugins/nginx-vhosts/go.sum | 23 ++ plugins/nginx-vhosts/template_test.go | 291 ++++++++++++++++++ .../nginx-vhosts/templates/nginx.conf.sigil | 149 +++------ tests/unit/nginx-vhosts_16.bats | 72 +++++ 5 files changed, 432 insertions(+), 111 deletions(-) create mode 100644 plugins/nginx-vhosts/template_test.go create mode 100644 tests/unit/nginx-vhosts_16.bats diff --git a/plugins/nginx-vhosts/go.mod b/plugins/nginx-vhosts/go.mod index ea93a1149..5e8f1496a 100644 --- a/plugins/nginx-vhosts/go.mod +++ b/plugins/nginx-vhosts/go.mod @@ -4,18 +4,25 @@ go 1.26.2 require ( github.com/dokku/dokku/plugins/common v0.0.0-00010101000000-000000000000 + github.com/gliderlabs/sigil v0.12.0 github.com/spf13/pflag v1.0.10 ) require ( github.com/alexellis/go-execute/v2 v2.2.1 // indirect + github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc // indirect + github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad // indirect github.com/fatih/color v1.19.0 // indirect + github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 // indirect github.com/hashicorp/errwrap v1.0.0 // indirect github.com/hashicorp/go-multierror v1.1.1 // indirect + github.com/jmespath/go-jmespath v0.4.0 // indirect + github.com/joho/godotenv v1.5.1 // indirect github.com/kr/fs v0.1.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect github.com/melbahja/goph v1.5.0 // indirect + github.com/mgood/go-posix v0.0.0-20150821180505-948c005421f5 // indirect github.com/otiai10/copy v1.14.1 // indirect github.com/otiai10/mint v1.6.3 // indirect github.com/pkg/errors v0.9.1 // indirect @@ -24,6 +31,7 @@ require ( golang.org/x/crypto v0.50.0 // indirect golang.org/x/sync v0.20.0 // indirect golang.org/x/sys v0.43.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect ) replace github.com/dokku/dokku/plugins/common => ../common diff --git a/plugins/nginx-vhosts/go.sum b/plugins/nginx-vhosts/go.sum index e973ec8a5..4b2bd60c3 100644 --- a/plugins/nginx-vhosts/go.sum +++ b/plugins/nginx-vhosts/go.sum @@ -1,15 +1,30 @@ github.com/alexellis/go-execute/v2 v2.2.1 h1:4Ye3jiCKQarstODOEmqDSRCqxMHLkC92Bhse743RdOI= github.com/alexellis/go-execute/v2 v2.2.1/go.mod h1:FMdRnUTiFAmYXcv23txrp3VYZfLo24nMpiIneWgKHTQ= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc h1:tP7tkU+vIsEOKiK+l/NSLN4uUtkyuxc6hgYpQeCWAeI= +github.com/dustin/go-jsonpointer v0.0.0-20160814072949-ba0abeacc3dc/go.mod h1:ORH5Qp2bskd9NzSfKqAF7tKfONsEkCarTE5ESr/RVBw= +github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad h1:Qk76DOWdOp+GlyDKBAG3Klr9cn7N+LcYc82AZ2S7+cA= +github.com/dustin/gojson v0.0.0-20160307161227-2e71ec9dd5ad/go.mod h1:mPKfmRa823oBIgl2r20LeMSpTAteW5j7FLkc0vjmzyQ= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568 h1:BHsljHzVlRcyQhjrss6TZTdY2VfCqZPbv5k3iBFa2ZQ= +github.com/flynn/go-shlex v0.0.0-20150515145356-3f9db97f8568/go.mod h1:xEzjJPgXI435gkrCt3MPfRiAkVrwSbHsst4LCFVfpJc= +github.com/gliderlabs/sigil v0.12.0 h1:2O+PDkqnn8XhUYx0z79MkFTik39t9FEdM1kgK4geQvg= +github.com/gliderlabs/sigil v0.12.0/go.mod h1:O6TGDuYPPHoiMad4W2FDsHgK39tNBYZRfhhUGOKgzRE= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/hashicorp/errwrap v1.0.0 h1:hLrqtEDnRye3+sgx6z4qVLNuviH3MR5aQ0ykNJa/UYA= github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4= github.com/hashicorp/go-multierror v1.1.1 h1:H5DkEtf6CXdFp0N0Em5UCwQpXMWke8IA0+lD48awMYo= github.com/hashicorp/go-multierror v1.1.1/go.mod h1:iw975J/qwKPdAO1clOe2L8331t/9/fmwbPZ6JB6eMoM= +github.com/jmespath/go-jmespath v0.4.0 h1:BEgLn5cpjn8UN1mAw4NjwDrS35OdebyEtFe+9YPoQUg= +github.com/jmespath/go-jmespath v0.4.0/go.mod h1:T8mJZnbsbmF+m6zOOFylbeCJqk5+pHWvzYPziyZiYoo= +github.com/jmespath/go-jmespath/internal/testify v1.5.1 h1:shLQSRRSCCPj3f2gpwzGwWFoC7ycTf1rcQZHOlsJ6N8= +github.com/jmespath/go-jmespath/internal/testify v1.5.1/go.mod h1:L3OGu8Wl2/fWfCI6z80xFu9LTZmf1ZRjMHUOPmWr69U= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= github.com/kr/fs v0.1.0 h1:Jskdu9ieNAYnjxsi0LbQp1ulIKZV1LAFgK1tWhpZgl8= github.com/kr/fs v0.1.0/go.mod h1:FFnZGqtBN9Gxj7eW1uZ42v5BccTP0vu6NEaFoC2HwRg= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -18,6 +33,8 @@ github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWE github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/melbahja/goph v1.5.0 h1:RQUBpLvfg3i7fjfG8rTcSWyMjVRfdhwrrfQhjYee4dQ= github.com/melbahja/goph v1.5.0/go.mod h1:dDwo+44cmvfDLdiVpc6fJxexf5BA5yEDUeE5YgtuDO4= +github.com/mgood/go-posix v0.0.0-20150821180505-948c005421f5 h1:AutzcJSqc7ROMqcjPKmxwLl//YrzDPb4tLSBlH3Irdc= +github.com/mgood/go-posix v0.0.0-20150821180505-948c005421f5/go.mod h1:irVTeKOI2GrudEK6/mqR4dDlGH91lCZIZM34YKSKH7M= github.com/onsi/gomega v1.40.0 h1:Vtol0e1MghCD2ZVIilPDIg44XSL9l2QAn8ZNaljWcJc= github.com/onsi/gomega v1.40.0/go.mod h1:M/Uqpu/8qTjtzCLUA2zJHX9Iilrau25x1PdoSRbWh5A= github.com/otiai10/copy v1.14.1 h1:5/7E6qsUMBaH5AnQ0sSLzzTg1oTECmcCmT6lvF45Na8= @@ -34,6 +51,7 @@ github.com/ryanuber/columnize v2.1.2+incompatible h1:C89EOx/XBWwIXl8wm8OPJBd7kPF github.com/ryanuber/columnize v2.1.2+incompatible/go.mod h1:sm1tb6uqfes/u+d4ooFouqFdy9/2g9QGwK3SQygK0Ts= github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk= github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= @@ -51,5 +69,10 @@ golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/plugins/nginx-vhosts/template_test.go b/plugins/nginx-vhosts/template_test.go new file mode 100644 index 000000000..5e7817357 --- /dev/null +++ b/plugins/nginx-vhosts/template_test.go @@ -0,0 +1,291 @@ +package nginxvhosts + +import ( + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "github.com/gliderlabs/sigil" + _ "github.com/gliderlabs/sigil/builtin" +) + +func templatePath(t *testing.T) string { + t.Helper() + _, file, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Join(filepath.Dir(file), "templates", "nginx.conf.sigil") +} + +func defaultVars() map[string]interface{} { + return map[string]interface{}{ + "APP": "app", + "DOKKU_ROOT": "/home/dokku", + "DOKKU_LIB_ROOT": "/var/lib/dokku", + "NOSSL_SERVER_NAME": "app.example.com", + "SSL_SERVER_NAME": "", + "SSL_INUSE": "", + "APP_SSL_PATH": "/home/dokku/app/tls", + "DOKKU_APP_WEB_LISTENERS": "127.0.0.1:5000", + "PROXY_PORT_MAP": "http:80:5000", + "PROXY_UPSTREAM_PORTS": "5000", + "PROXY_PORT": "80", + "PROXY_SSL_PORT": "443", + "PROXY_KEEPALIVE": "", + "NGINX_BIND_ADDRESS_IP4": "", + "NGINX_BIND_ADDRESS_IP6": "::", + "NGINX_ACCESS_LOG_PATH": "/var/log/nginx/app-access.log", + "NGINX_ACCESS_LOG_FORMAT": "", + "NGINX_ERROR_LOG_PATH": "/var/log/nginx/app-error.log", + "NGINX_UNDERSCORE_IN_HEADERS": "off", + "CLIENT_BODY_TIMEOUT": "60s", + "CLIENT_HEADER_TIMEOUT": "60s", + "CLIENT_MAX_BODY_SIZE": "1m", + "KEEPALIVE_TIMEOUT": "75s", + "LINGERING_TIMEOUT": "5s", + "SEND_TIMEOUT": "60s", + "PROXY_CONNECT_TIMEOUT": "60s", + "PROXY_READ_TIMEOUT": "60s", + "PROXY_SEND_TIMEOUT": "60s", + "PROXY_BUFFER_SIZE": "4k", + "PROXY_BUFFERING": "on", + "PROXY_BUFFERS": "8 4k", + "PROXY_BUSY_BUFFERS_SIZE": "8k", + "PROXY_X_FORWARDED_FOR": "$remote_addr", + "PROXY_X_FORWARDED_PORT": "$server_port", + "PROXY_X_FORWARDED_PROTO": "$scheme", + "PROXY_X_FORWARDED_SSL": "", + "HTTP2_DIRECTIVE_SUPPORTED": "true", + } +} + +func renderTemplate(t *testing.T, vars map[string]interface{}) string { + t.Helper() + data, err := os.ReadFile(templatePath(t)) + if err != nil { + t.Fatalf("read template: %v", err) + } + buf, err := sigil.Execute(data, vars, "nginx.conf.sigil") + if err != nil { + t.Fatalf("sigil.Execute: %v", err) + } + return buf.String() +} + +func mustContain(t *testing.T, out, needle string) { + t.Helper() + if !strings.Contains(out, needle) { + t.Errorf("expected output to contain %q\n--- output ---\n%s", needle, out) + } +} + +func mustNotContain(t *testing.T, out, needle string) { + t.Helper() + if strings.Contains(out, needle) { + t.Errorf("expected output NOT to contain %q\n--- output ---\n%s", needle, out) + } +} + +func TestTemplate_HTTPOnlyBasicProxy(t *testing.T) { + out := renderTemplate(t, defaultVars()) + mustContain(t, out, "listen [::]:80;") + mustContain(t, out, "proxy_pass http://app-5000;") + mustContain(t, out, "error_page 500 501 502 503") + mustContain(t, out, "server_name app.example.com;") + mustNotContain(t, out, "ssl_certificate") + mustNotContain(t, out, "return 301 https") + mustNotContain(t, out, "http2_push_preload") +} + +func TestTemplate_HTTPSEmitsPushPreloadWhenSupported(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + v["HTTP2_PUSH_SUPPORTED"] = "true" + out := renderTemplate(t, v) + mustContain(t, out, "http2_push_preload on;") +} + +func TestTemplate_HTTPSOmitsPushPreloadWhenUnsupported(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + v["HTTP2_PUSH_SUPPORTED"] = "false" + out := renderTemplate(t, v) + mustNotContain(t, out, "http2_push_preload") +} + +func TestTemplate_HTTPRedirectsToHTTPSWhenSSLInUse(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "http:80:5000 https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + out := renderTemplate(t, v) + + port80, _, ok := strings.Cut(out, "listen [::]:443") + if !ok { + t.Fatalf("expected an https server block in output:\n%s", out) + } + mustContain(t, port80, "return 301 https://$host:443$request_uri;") + mustContain(t, port80, "include /home/dokku/app/nginx.conf.d/*.conf;") + mustNotContain(t, port80, "proxy_pass http://app-5000;") + + mustContain(t, out, "ssl_certificate /home/dokku/app/tls/server.crt;") + mustContain(t, out, "proxy_pass http://app-5000;") +} + +func TestTemplate_HTTPSWithHTTP2Directive(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + v["HTTP2_DIRECTIVE_SUPPORTED"] = "true" + out := renderTemplate(t, v) + mustContain(t, out, "listen [::]:443 ssl;") + mustContain(t, out, "http2 on;") + mustNotContain(t, out, "listen [::]:443 ssl http2;") +} + +func TestTemplate_HTTPSWithHTTP2Parameter(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + v["HTTP2_DIRECTIVE_SUPPORTED"] = "false" + out := renderTemplate(t, v) + mustContain(t, out, "listen [::]:443 ssl http2;") + mustNotContain(t, out, "http2 on;") +} + +func TestTemplate_NoWebListenersReturns502(t *testing.T) { + v := defaultVars() + v["DOKKU_APP_WEB_LISTENERS"] = "" + out := renderTemplate(t, v) + mustContain(t, out, "return 502;") + mustNotContain(t, out, "proxy_pass http://app-5000;") +} + +func TestTemplate_GRPCNoSSL(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "grpc:50051:50051" + v["PROXY_UPSTREAM_PORTS"] = "50051" + out := renderTemplate(t, v) + mustContain(t, out, "grpc_pass grpc://app-50051;") + mustContain(t, out, "http2") + mustNotContain(t, out, "ssl_certificate") +} + +func TestTemplate_GRPCS(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "grpcs:443:50051" + v["PROXY_UPSTREAM_PORTS"] = "50051" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + out := renderTemplate(t, v) + mustContain(t, out, "ssl_certificate /home/dokku/app/tls/server.crt;") + mustContain(t, out, "grpc_pass grpc://app-50051;") +} + +func TestTemplate_GRPCSkippedWithoutListeners(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "grpc:50051:50051" + v["PROXY_UPSTREAM_PORTS"] = "50051" + v["DOKKU_APP_WEB_LISTENERS"] = "" + out := renderTemplate(t, v) + mustNotContain(t, out, "grpc_pass") + mustNotContain(t, out, "server {") +} + +func TestTemplate_BindAddressIPv4(t *testing.T) { + v := defaultVars() + v["NGINX_BIND_ADDRESS_IP4"] = "127.0.0.1" + out := renderTemplate(t, v) + mustContain(t, out, "listen 127.0.0.1:80;") + + v2 := defaultVars() + out2 := renderTemplate(t, v2) + mustNotContain(t, out2, "listen 127.0.0.1") + mustNotContain(t, out2, ":80;\n listen :80;") +} + +func TestTemplate_UpstreamBlock(t *testing.T) { + v := defaultVars() + v["PROXY_PORT_MAP"] = "http:80:5000 http:8080:5001" + v["PROXY_UPSTREAM_PORTS"] = "5000 5001" + v["DOKKU_APP_WEB_LISTENERS"] = "10.0.0.1:5000 10.0.0.2:5000" + out := renderTemplate(t, v) + mustContain(t, out, "upstream app-5000 {") + mustContain(t, out, "upstream app-5001 {") + mustContain(t, out, "server 10.0.0.1:5000;") + mustContain(t, out, "server 10.0.0.2:5000;") + mustContain(t, out, "server 10.0.0.1:5001;") + mustContain(t, out, "server 10.0.0.2:5001;") +} + +func TestTemplate_UpstreamWithKeepalive(t *testing.T) { + v := defaultVars() + v["PROXY_KEEPALIVE"] = "16" + out := renderTemplate(t, v) + mustContain(t, out, "keepalive 16;") + + v2 := defaultVars() + out2 := renderTemplate(t, v2) + mustNotContain(t, out2, "keepalive 16;") +} + +func TestTemplate_AccessLogFormat(t *testing.T) { + v := defaultVars() + v["NGINX_ACCESS_LOG_FORMAT"] = "json" + v["NGINX_ACCESS_LOG_PATH"] = "/var/log/nginx/app-access.log" + out := renderTemplate(t, v) + mustContain(t, out, "access_log /var/log/nginx/app-access.log json;") + + v2 := defaultVars() + v2["NGINX_ACCESS_LOG_FORMAT"] = "json" + v2["NGINX_ACCESS_LOG_PATH"] = "off" + out2 := renderTemplate(t, v2) + mustContain(t, out2, "access_log off;") + mustNotContain(t, out2, "access_log off json;") +} + +func TestTemplate_XForwardedSSL(t *testing.T) { + v := defaultVars() + v["PROXY_X_FORWARDED_SSL"] = "on" + out := renderTemplate(t, v) + mustContain(t, out, "proxy_set_header X-Forwarded-Ssl on;") + + v2 := defaultVars() + out2 := renderTemplate(t, v2) + mustNotContain(t, out2, "X-Forwarded-Ssl") +} + +func TestTemplate_NginxConfDIncludeAlways(t *testing.T) { + cases := []struct { + name string + mutate func(map[string]interface{}) + }{ + {"http", func(v map[string]interface{}) {}}, + {"http_redirect", func(v map[string]interface{}) { + v["PROXY_PORT_MAP"] = "http:80:5000 https:443:5000" + v["SSL_INUSE"] = "true" + v["SSL_SERVER_NAME"] = "app.example.com" + }}, + {"grpc", func(v map[string]interface{}) { + v["PROXY_PORT_MAP"] = "grpc:50051:50051" + v["PROXY_UPSTREAM_PORTS"] = "50051" + }}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + v := defaultVars() + tc.mutate(v) + out := renderTemplate(t, v) + mustContain(t, out, "include /home/dokku/app/nginx.conf.d/*.conf;") + }) + } +} diff --git a/plugins/nginx-vhosts/templates/nginx.conf.sigil b/plugins/nginx-vhosts/templates/nginx.conf.sigil index d30b8fe9a..9687e6a56 100644 --- a/plugins/nginx-vhosts/templates/nginx.conf.sigil +++ b/plugins/nginx-vhosts/templates/nginx.conf.sigil @@ -4,10 +4,24 @@ {{ $listen_port := index $port_map_list 1 }} {{ $upstream_port := index $port_map_list 2 }} -{{ if eq $scheme "http" }} +{{ if or (eq $scheme "http") (eq $scheme "https") }} +{{ $is_ssl := eq $scheme "https" }} +{{ $is_redirect := and (not $is_ssl) (eq $listen_port "80") $.SSL_INUSE }} server { + {{ if $is_ssl }} + {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl; + http2 on; + {{ else }} + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl http2; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl http2; + {{ end }} + {{ else }} listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}; listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }}; + {{ end }} + {{ if and $is_ssl $.SSL_SERVER_NAME }}server_name {{ $.SSL_SERVER_NAME }}; {{ end }} {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; error_log {{ $.NGINX_ERROR_LOG_PATH }}; @@ -19,7 +33,14 @@ server { lingering_timeout {{ $.LINGERING_TIMEOUT }}; send_timeout {{ $.SEND_TIMEOUT }}; -{{ if (and (eq $listen_port "80") ($.SSL_INUSE)) }} + {{ if $is_ssl }} + ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; + ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_prefer_server_ciphers off; + {{ end }} + +{{ if $is_redirect }} include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; location / { return 301 https://$host:{{ $.PROXY_SSL_PORT }}$request_uri; @@ -36,6 +57,7 @@ server { gzip_comp_level 6; proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; + {{ if and $is_ssl (eq $.HTTP2_PUSH_SUPPORTED "true") }}http2_push_preload on; {{ end }} proxy_http_version 1.1; proxy_connect_timeout {{ $.PROXY_CONNECT_TIMEOUT }}; proxy_read_timeout {{ $.PROXY_READ_TIMEOUT }}; @@ -71,144 +93,49 @@ server { internal; } - error_page 500 501 502 503 504 505 506 507 508 509 510 511 /500-error.html; - location /500-error.html { - root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; - internal; - } - include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; -{{ end }} -} -{{ else if eq $scheme "https"}} -server { - {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} - listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl; - listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl; - http2 on; - {{ else }} - listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl http2; - listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl http2; - {{ end }} - {{ if $.SSL_SERVER_NAME }}server_name {{ $.SSL_SERVER_NAME }}; {{ end }} - {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} - access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; - error_log {{ $.NGINX_ERROR_LOG_PATH }}; - underscores_in_headers {{ $.NGINX_UNDERSCORE_IN_HEADERS }}; - - ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; - ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; - ssl_protocols TLSv1.2 TLSv1.3; - ssl_prefer_server_ciphers off; - - client_body_timeout {{ $.CLIENT_BODY_TIMEOUT }}; - client_header_timeout {{ $.CLIENT_HEADER_TIMEOUT }}; - keepalive_timeout {{ $.KEEPALIVE_TIMEOUT }}; - lingering_timeout {{ $.LINGERING_TIMEOUT }}; - send_timeout {{ $.SEND_TIMEOUT }}; - - location / { -{{ if $.DOKKU_APP_WEB_LISTENERS }} - - gzip on; - gzip_min_length 1100; - gzip_buffers 4 32k; - gzip_types text/css text/javascript text/xml text/plain text/x-component application/javascript application/x-javascript application/json application/graphql-response+json application/xml application/rss+xml font/truetype application/x-font-ttf font/opentype application/vnd.ms-fontobject image/svg+xml; - gzip_vary on; - gzip_comp_level 6; - - proxy_pass http://{{ $.APP }}-{{ $upstream_port }}; - {{ if eq $.HTTP2_PUSH_SUPPORTED "true" }}http2_push_preload on; {{ end }} - proxy_http_version 1.1; - proxy_connect_timeout {{ $.PROXY_CONNECT_TIMEOUT }}; - proxy_read_timeout {{ $.PROXY_READ_TIMEOUT }}; - proxy_send_timeout {{ $.PROXY_SEND_TIMEOUT }}; - proxy_buffer_size {{ $.PROXY_BUFFER_SIZE }}; - proxy_buffering {{ $.PROXY_BUFFERING }}; - proxy_buffers {{ $.PROXY_BUFFERS }}; - proxy_busy_buffers_size {{ $.PROXY_BUSY_BUFFERS_SIZE }}; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection $http_connection; - proxy_set_header Host $http_host; - proxy_set_header X-Forwarded-For {{ $.PROXY_X_FORWARDED_FOR }}; - proxy_set_header X-Forwarded-Port {{ $.PROXY_X_FORWARDED_PORT }}; - proxy_set_header X-Forwarded-Proto {{ $.PROXY_X_FORWARDED_PROTO }}; - proxy_set_header X-Request-Start $msec; - {{ if $.PROXY_X_FORWARDED_SSL }}proxy_set_header X-Forwarded-Ssl {{ $.PROXY_X_FORWARDED_SSL }};{{ end }} -{{ else }} - return 502; -{{ end }} - } - - client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; - - error_page 400 401 402 403 405 406 407 408 409 410 411 412 413 414 415 416 417 418 420 422 423 424 426 428 429 431 444 449 450 451 /400-error.html; - location /400-error.html { - root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; - internal; - } - - error_page 404 /404-error.html; - location /404-error.html { - root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; - internal; - } - + {{ if $is_ssl }} error_page 500 501 503 504 505 506 507 508 509 510 511 /500-error.html; + {{ else }} + error_page 500 501 502 503 504 505 506 507 508 509 510 511 /500-error.html; + {{ end }} location /500-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } + {{ if $is_ssl }} error_page 502 /502-error.html; location /502-error.html { root {{ $.DOKKU_LIB_ROOT }}/data/nginx-vhosts/dokku-errors; internal; } - include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; -} -{{ else if eq $scheme "grpc"}} -{{ if $.DOKKU_APP_WEB_LISTENERS }} -server { - {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} - listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}; - listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }}; - http2 on; - {{ else }} - listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} http2; - listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} http2; {{ end }} - {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} - access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; - error_log {{ $.NGINX_ERROR_LOG_PATH }}; - underscores_in_headers {{ $.NGINX_UNDERSCORE_IN_HEADERS }}; - location / { - grpc_pass grpc://{{ $.APP }}-{{ $upstream_port }}; - } - - client_max_body_size {{ $.CLIENT_MAX_BODY_SIZE }}; include {{ $.DOKKU_ROOT }}/{{ $.APP }}/nginx.conf.d/*.conf; -} {{ end }} -{{ else if eq $scheme "grpcs"}} +} +{{ else if or (eq $scheme "grpc") (eq $scheme "grpcs") }} {{ if $.DOKKU_APP_WEB_LISTENERS }} +{{ $is_ssl := eq $scheme "grpcs" }} server { {{ if eq $.HTTP2_DIRECTIVE_SUPPORTED "true" }} - listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl; - listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl; + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}{{ if $is_ssl }} ssl{{ end }}; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }}{{ if $is_ssl }} ssl{{ end }}; http2 on; {{ else }} - listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }} ssl http2; - listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }} ssl http2; + listen [{{ $.NGINX_BIND_ADDRESS_IP6 }}]:{{ $listen_port }}{{ if $is_ssl }} ssl{{ end }} http2; + listen {{ if $.NGINX_BIND_ADDRESS_IP4 }}{{ $.NGINX_BIND_ADDRESS_IP4 }}:{{end}}{{ $listen_port }}{{ if $is_ssl }} ssl{{ end }} http2; {{ end }} {{ if $.NOSSL_SERVER_NAME }}server_name {{ $.NOSSL_SERVER_NAME }}; {{ end }} access_log {{ $.NGINX_ACCESS_LOG_PATH }}{{ if and ($.NGINX_ACCESS_LOG_FORMAT) (ne $.NGINX_ACCESS_LOG_PATH "off") }} {{ $.NGINX_ACCESS_LOG_FORMAT }}{{ end }}; error_log {{ $.NGINX_ERROR_LOG_PATH }}; underscores_in_headers {{ $.NGINX_UNDERSCORE_IN_HEADERS }}; + {{ if $is_ssl }} ssl_certificate {{ $.APP_SSL_PATH }}/server.crt; ssl_certificate_key {{ $.APP_SSL_PATH }}/server.key; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; + {{ end }} location / { grpc_pass grpc://{{ $.APP }}-{{ $upstream_port }}; diff --git a/tests/unit/nginx-vhosts_16.bats b/tests/unit/nginx-vhosts_16.bats new file mode 100644 index 000000000..d1c3fec38 --- /dev/null +++ b/tests/unit/nginx-vhosts_16.bats @@ -0,0 +1,72 @@ +#!/usr/bin/env bats + +load test_helper + +setup_local_tls() { + TLS=$BATS_TMPDIR/tls + mkdir -p $TLS + tar xf $BATS_TEST_DIRNAME/server_ssl.tar -C $TLS + tar xf $BATS_TEST_DIRNAME/domain_ssl.tar -C $TLS + sudo chown -R dokku:dokku $TLS +} + +teardown_local_tls() { + TLS=$BATS_TMPDIR/tls + rm -R $TLS +} + +setup() { + global_setup + [[ -f "$DOKKU_ROOT/VHOST" ]] && cp -fp "$DOKKU_ROOT/VHOST" "$DOKKU_ROOT/VHOST.bak" + create_app +} + +teardown() { + destroy_app + [[ -f "$DOKKU_ROOT/VHOST.bak" ]] && mv "$DOKKU_ROOT/VHOST.bak" "$DOKKU_ROOT/VHOST" && chown dokku:dokku "$DOKKU_ROOT/VHOST" + global_teardown +} + +@test "(nginx-vhosts) refactored template generates nginx -t parseable http config" { + run deploy_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "test -f $DOKKU_ROOT/$TEST_APP/nginx.conf" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_success +} + +@test "(nginx-vhosts) refactored template generates nginx -t parseable https config" { + setup_local_tls + run deploy_app + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku certs:add $TEST_APP $BATS_TMPDIR/tls/server.crt $BATS_TMPDIR/tls/server.key" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "sudo nginx -t" + echo "output: $output" + echo "status: $status" + assert_success + + run /bin/bash -c "dokku nginx:show-config $TEST_APP" + echo "output: $output" + echo "status: $status" + assert_success + assert_output_contains "ssl_certificate" + assert_output_contains "return 301 https" + teardown_local_tls +} + From ae3d63668d54afb6550d0672a158678070af1764 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 16:38:43 -0400 Subject: [PATCH 2/3] fix: drop trailing blank line in nginx-vhosts_16.bats --- tests/unit/nginx-vhosts_16.bats | 1 - 1 file changed, 1 deletion(-) diff --git a/tests/unit/nginx-vhosts_16.bats b/tests/unit/nginx-vhosts_16.bats index d1c3fec38..6b3865d23 100644 --- a/tests/unit/nginx-vhosts_16.bats +++ b/tests/unit/nginx-vhosts_16.bats @@ -69,4 +69,3 @@ teardown() { assert_output_contains "return 301 https" teardown_local_tls } - From dc5b9cc883b38e437be730954bf1314169d61146 Mon Sep 17 00:00:00 2001 From: Jose Diaz-Gonzalez Date: Thu, 30 Apr 2026 17:30:03 -0400 Subject: [PATCH 3/3] test: relax ssl_certificate occurrence count in nginx-vhosts_16 --- tests/unit/nginx-vhosts_16.bats | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/unit/nginx-vhosts_16.bats b/tests/unit/nginx-vhosts_16.bats index 6b3865d23..33c907adf 100644 --- a/tests/unit/nginx-vhosts_16.bats +++ b/tests/unit/nginx-vhosts_16.bats @@ -65,7 +65,7 @@ teardown() { echo "output: $output" echo "status: $status" assert_success - assert_output_contains "ssl_certificate" - assert_output_contains "return 301 https" + assert_output_contains "ssl_certificate" -1 + assert_output_contains "return 301 https" -1 teardown_local_tls }