From 506c16e0de9d867881dd65a50120ba58d1f7fb20 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 19 Oct 2026 18:59:16 -0400 Subject: [PATCH 1/2] fix(nginx): fronted projects emit TLS only when the serving stack holds the cert pair (D-0572) A project with NGINX_FRONTED_BY is served by the fronting stack's nginx, which mounts its own ssl/. The TLS decision read the project's own ssl/ (or assumed true in local mode), so site confs and plugin snippets could name a certificate the serving nginx cannot load. Check the pair in nginxtopo.ServedSSLDir for every mode but none. A shared-proxy owner keeps the existing flow, where the build copies the pair in. --- .github/wiki/Config-Nginx.md | 2 + internal/nginx/ssl_detect.go | 43 +++++++++++- internal/nginx/ssl_detect_fronted_test.go | 80 +++++++++++++++++++++++ 3 files changed, 124 insertions(+), 1 deletion(-) create mode 100644 internal/nginx/ssl_detect_fronted_test.go diff --git a/.github/wiki/Config-Nginx.md b/.github/wiki/Config-Nginx.md index 0f4b9439c..255e03dc1 100644 --- a/.github/wiki/Config-Nginx.md +++ b/.github/wiki/Config-Nginx.md @@ -201,6 +201,8 @@ NGINX_FRONTED_BY=nself-web With this set, `nself build` omits the Nginx service from the generated `docker-compose.yml` entirely, and `nself status` stops counting an Nginx container for this project. Leaving it unset is the default and changes nothing for a normal single-project stack. +The fronting stack's Nginx reads certificates from its own `ssl/` directory. A fronted project therefore gets HTTPS server blocks (and plugin snippets that reference a certificate) only when `ssl/certificates//fullchain.pem` and `privkey.pem` exist in the fronting stack's `ssl/`, whatever `SSL_MODE` is (`none` still turns TLS off). Without the pair there, the build writes HTTP-only confs and skips TLS plugin snippets with a warning, instead of generating a conf that Nginx cannot load. Under `NGINX_MODE=shared` the build copies the project's pair into the owner's `ssl/` itself, so that rule does not apply. + Without it, a fronted project still generates an Nginx that can never start: `nself start` fails its port check with 80/443 held by the fronting stack's `docker-proxy`, and `nself status` reports one permanently unhealthy service that nothing can clear. **This flag removes the container that cannot run. It does not wire the two projects' networks together.** The fronting Nginx resolves upstreams through Docker's embedded DNS at `127.0.0.11`, which only answers for container names on a network that Nginx is attached to. Routing `auth.task.example.com` to this project's `auth` container still requires the two projects to share a Docker network — declared as `external` in both compose files, or attached by hand. Automatically rewriting a project's network topology from a single flag is a larger change and is deliberately not part of this one. diff --git a/internal/nginx/ssl_detect.go b/internal/nginx/ssl_detect.go index 448285132..98f75c45c 100644 --- a/internal/nginx/ssl_detect.go +++ b/internal/nginx/ssl_detect.go @@ -5,6 +5,8 @@ import ( "path/filepath" "github.com/nself-org/cli/internal/config" + "github.com/nself-org/cli/internal/nginxtopo" + "github.com/nself-org/cli/internal/sharedproxy" "github.com/nself-org/cli/internal/ssl" ) @@ -42,6 +44,14 @@ import ( // - any other mode ("letsencrypt", "custom", ...) checks the filesystem: // these modes never generate certs themselves, so presence on disk is // the only trustworthy signal that nginx can safely reference them. +// - a fronted project (NGINX_FRONTED_BY) is served by the fronting stack's +// nginx, which mounts its own ssl/ (D-0572): for every mode but "none" the +// pair must be present there, so neither site confs nor plugin snippets +// name a certificate the serving nginx cannot load. The project's own +// ssl/ (written by this build) and assumed plan-mode pairs do not count. +// Exception: under a shared proxy (NGINX_MODE=shared owner) the build +// copies the project's pair into the owner's ssl/ itself +// (internal/build/shared_proxy_certs.go), so the rules below apply. // - assumed lists domains whose certificate pair a plan-mode build would // create but has not (WithAssumedCerts): a pair for cfg.BaseDomain among // them counts as present, so a plan keeps the TLS blocks apply produces. @@ -49,6 +59,11 @@ func sslShouldEmit(cfg *config.Config, workdir, mode string, assumed []string) b switch mode { case "none": return false + } + if cfg.Nginx.FrontedBy != "" && !sharedProxyFronted(cfg, workdir) { + return servedCertsPresent(cfg, workdir) + } + switch mode { case "local": return true default: @@ -87,7 +102,33 @@ func sslDirName(domain string) string { } func sslCertsPresent(cfg *config.Config, workdir string) bool { - certDir := filepath.Join(workdir, "ssl", "certificates", sslDirName(cfg.BaseDomain)) + return certPairPresent(filepath.Join(workdir, "ssl"), cfg.BaseDomain) +} + +// servedCertsPresent is sslCertsPresent against the ssl dir the fronting +// stack's nginx mounts (nginxtopo.ServedSSLDir). An unconfirmed fronting +// layout reads as absent: the build refuses it anyway (resolveNginxSitesDir). +func servedCertsPresent(cfg *config.Config, workdir string) bool { + dir, err := nginxtopo.ServedSSLDir(workdir, cfg.Nginx.FrontedBy) + return err == nil && certPairPresent(dir, cfg.BaseDomain) +} + +// sharedProxyFronted reports whether the fronting stack is a shared-proxy +// owner, whose registration makes the build copy this project's pair into the +// owner's ssl/. +func sharedProxyFronted(cfg *config.Config, workdir string) bool { + root, err := nginxtopo.ServedRoot(workdir, cfg.Nginx.FrontedBy) + if err != nil { + return false + } + _, err = os.Stat(filepath.Join(sharedproxy.DirOf(root), sharedproxy.OwnerFile)) + return err == nil +} + +// certPairPresent reports whether fullchain.pem and privkey.pem exist under +// /certificates/. +func certPairPresent(sslDir, domain string) bool { + certDir := filepath.Join(sslDir, "certificates", sslDirName(domain)) if _, err := os.Stat(filepath.Join(certDir, "fullchain.pem")); err != nil { return false } diff --git a/internal/nginx/ssl_detect_fronted_test.go b/internal/nginx/ssl_detect_fronted_test.go new file mode 100644 index 000000000..4a4aa6629 --- /dev/null +++ b/internal/nginx/ssl_detect_fronted_test.go @@ -0,0 +1,80 @@ +package nginx + +import ( + "os" + "path/filepath" + "testing" + + "github.com/nself-org/cli/internal/sharedproxy" +) + +// ssl_detect_fronted_test.go — D-0572: a fronted project (NGINX_FRONTED_BY) is +// served by the fronting stack's nginx, so TLS blocks follow the certificate +// pair in the fronting stack's ssl/, not the project's own. + +// frontedLayout returns /nself-web/backend (the project) and the +// fronting stack dir /nself-web. +func frontedLayout(t *testing.T) (project, stack string) { + t.Helper() + stack = filepath.Join(t.TempDir(), "nself-web") + project = filepath.Join(stack, "backend") + if err := os.MkdirAll(project, 0o755); err != nil { + t.Fatal(err) + } + return project, stack +} + +func TestSSLShouldEmitFronted(t *testing.T) { + cases := []struct { + name string + mode string + ownPair bool // pair in the project's own ssl/ + servedPair bool // pair in the fronting stack's ssl/ + frontedBy string + assumedOwn bool + sharedOwner bool // the fronting stack is a shared-proxy owner + want bool + }{ + {name: "local, only own pair", mode: "local", ownPair: true, frontedBy: "nself-web", want: false}, + {name: "local, served pair", mode: "local", servedPair: true, frontedBy: "nself-web", want: true}, + {name: "letsencrypt, only own pair", mode: "letsencrypt", ownPair: true, frontedBy: "nself-web", want: false}, + {name: "letsencrypt, served pair", mode: "letsencrypt", servedPair: true, frontedBy: "nself-web", want: true}, + {name: "custom, assumed plan pair does not count", mode: "custom", frontedBy: "nself-web", assumedOwn: true, want: false}, + {name: "none stays off with served pair", mode: "none", servedPair: true, frontedBy: "nself-web", want: false}, + {name: "unconfirmed layout reads as absent", mode: "local", ownPair: true, frontedBy: "other-stack", want: false}, + {name: "shared owner, local keeps the copy-in flow", mode: "local", frontedBy: "nself-web", sharedOwner: true, want: true}, + {name: "shared owner, letsencrypt with own pair", mode: "letsencrypt", ownPair: true, frontedBy: "nself-web", sharedOwner: true, want: true}, + {name: "shared owner, letsencrypt without pair", mode: "letsencrypt", frontedBy: "nself-web", sharedOwner: true, want: false}, + {name: "unfronted local is unchanged", mode: "local", frontedBy: "", want: true}, + {name: "unfronted letsencrypt with own pair is unchanged", mode: "letsencrypt", ownPair: true, frontedBy: "", want: true}, + } + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + project, stack := frontedLayout(t) + cfg := newCfgForSSLDetect(t, c.mode) + cfg.Nginx.FrontedBy = c.frontedBy + if c.ownPair { + writeCertPair(t, project, cfg.BaseDomain) + } + if c.servedPair { + writeCertPair(t, stack, cfg.BaseDomain) + } + if c.sharedOwner { + dir := sharedproxy.DirOf(stack) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, sharedproxy.OwnerFile), []byte("{}"), 0o644); err != nil { + t.Fatal(err) + } + } + var assumed []string + if c.assumedOwn { + assumed = []string{cfg.BaseDomain} + } + if got := sslShouldEmit(cfg, project, c.mode, assumed); got != c.want { + t.Fatalf("sslShouldEmit = %v, want %v", got, c.want) + } + }) + } +} From cac4d6dd5144f612df68345f0398a8dedb5bd201 Mon Sep 17 00:00:00 2001 From: Aric Camarata Date: Mon, 19 Oct 2026 20:29:31 -0400 Subject: [PATCH 2/2] fix(nginx): ask nginxtopo whether the fronting stack is a shared-proxy owner (D-0572) internal/nginx must not import internal/sharedproxy (same-layer edge). nginxtopo owns the owner-file path; sharedproxy re-exports it. --- internal/nginx/ssl_detect.go | 4 +--- internal/nginx/ssl_detect_fronted_test.go | 6 +++--- internal/nginxtopo/served.go | 18 ++++++++++++++++++ internal/sharedproxy/registration.go | 4 ++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/nginx/ssl_detect.go b/internal/nginx/ssl_detect.go index 98f75c45c..a674a4fec 100644 --- a/internal/nginx/ssl_detect.go +++ b/internal/nginx/ssl_detect.go @@ -6,7 +6,6 @@ import ( "github.com/nself-org/cli/internal/config" "github.com/nself-org/cli/internal/nginxtopo" - "github.com/nself-org/cli/internal/sharedproxy" "github.com/nself-org/cli/internal/ssl" ) @@ -121,8 +120,7 @@ func sharedProxyFronted(cfg *config.Config, workdir string) bool { if err != nil { return false } - _, err = os.Stat(filepath.Join(sharedproxy.DirOf(root), sharedproxy.OwnerFile)) - return err == nil + return nginxtopo.SharedOwnerRegistered(root) } // certPairPresent reports whether fullchain.pem and privkey.pem exist under diff --git a/internal/nginx/ssl_detect_fronted_test.go b/internal/nginx/ssl_detect_fronted_test.go index 4a4aa6629..0d5efe82d 100644 --- a/internal/nginx/ssl_detect_fronted_test.go +++ b/internal/nginx/ssl_detect_fronted_test.go @@ -5,7 +5,7 @@ import ( "path/filepath" "testing" - "github.com/nself-org/cli/internal/sharedproxy" + "github.com/nself-org/cli/internal/nginxtopo" ) // ssl_detect_fronted_test.go — D-0572: a fronted project (NGINX_FRONTED_BY) is @@ -60,11 +60,11 @@ func TestSSLShouldEmitFronted(t *testing.T) { writeCertPair(t, stack, cfg.BaseDomain) } if c.sharedOwner { - dir := sharedproxy.DirOf(stack) + dir := filepath.Join(stack, "nginx", nginxtopo.SharedDir) if err := os.MkdirAll(dir, 0o755); err != nil { t.Fatal(err) } - if err := os.WriteFile(filepath.Join(dir, sharedproxy.OwnerFile), []byte("{}"), 0o644); err != nil { + if err := os.WriteFile(filepath.Join(dir, nginxtopo.SharedOwnerFile), []byte("{}"), 0o644); err != nil { t.Fatal(err) } } diff --git a/internal/nginxtopo/served.go b/internal/nginxtopo/served.go index 0f477d837..fd35f225c 100644 --- a/internal/nginxtopo/served.go +++ b/internal/nginxtopo/served.go @@ -22,6 +22,7 @@ package nginxtopo import ( "errors" "fmt" + "os" "path/filepath" ) @@ -73,3 +74,20 @@ func ServedSSLDir(projectDir, frontedBy string) (string, error) { } return filepath.Join(root, "ssl"), nil } + +// SharedDir and SharedOwnerFile name the shared-proxy registration directory +// (/nginx/shared) and the file that marks a stack as a shared-proxy +// owner. internal/sharedproxy re-exports them; they live here so lower layers +// can ask the question without importing it. +const ( + SharedDir = "shared" + SharedOwnerFile = "_owner.json" +) + +// SharedOwnerRegistered reports whether the stack rooted at servedRoot is a +// shared-proxy owner: its nginx/shared/_owner.json exists. +func SharedOwnerRegistered(servedRoot string) bool { + nginxDir, _ := ServedNginxDir(servedRoot, "") // cannot fail with an empty frontedBy + _, err := os.Stat(filepath.Join(nginxDir, SharedDir, SharedOwnerFile)) + return err == nil +} diff --git a/internal/sharedproxy/registration.go b/internal/sharedproxy/registration.go index 8a16a5f9a..5c2db7541 100644 --- a/internal/sharedproxy/registration.go +++ b/internal/sharedproxy/registration.go @@ -31,8 +31,8 @@ import ( const ( Generated = "GENERATED BY nself build - DO NOT HAND EDIT" SchemaVersion = "1" - OwnerFile = "_owner.json" - Dir = "shared" + OwnerFile = nginxtopo.SharedOwnerFile + Dir = nginxtopo.SharedDir ) // Registration is one project's entry. Field order is the key order of the